Merge commit 'd989d598f18227e677f762c15c21df27013a57ad' into bundler-1/work
# Conflicts: # public/doxa-maps/index.html
This commit is contained in:
commit
4cfe8ec5e2
77 changed files with 192937 additions and 2081 deletions
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash .claude/statusline-plugin.sh",
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# statusline-plugin.sh — "which plugin is this agent working on?", in one terminal line.
|
||||
#
|
||||
# WHY: several builder agents run at once against this repo, each in its own worktree, and from the
|
||||
# outside every one of them looks like a Claude terminal printing tool calls. Asking "which plugin
|
||||
# are you on?" cost a round trip every time. This answers it continuously, without being asked.
|
||||
#
|
||||
# Renders: doxa-maps · bundler-2/work · ▶ research-map-plus-grid (cap-4)
|
||||
#
|
||||
# ★ THE TIER COMES FROM THE MANIFEST, NOT FROM A TABLE RETYPED HERE.
|
||||
# plugin-studio.vue derives every badge with PT/capFor off the manifest's profile `type`, so
|
||||
# retyping a profile there moves its badge with zero edits. A second hardcoded copy of that
|
||||
# mapping in this script would be a fact stored twice with nothing forcing agreement — the exact
|
||||
# shape check:capability-citations exists to catch (the README once captioned a bundle as cap-1
|
||||
# while the manifest said cap-2). So this reads manifest.json and applies 5 - PT[type], the one
|
||||
# formula, to the one source.
|
||||
#
|
||||
# ★ FAIL-SOFT EVERYWHERE, ON PURPOSE. A status line runs on every render. If it errors, is slow, or
|
||||
# prints a stack trace, it makes the terminal worse than having none — so every step degrades to a
|
||||
# quieter line instead of failing: no git → no branch, no manifest → no tier, nothing edited → "idle".
|
||||
# It never exits non-zero.
|
||||
#
|
||||
# Wired up by .claude/settings.json (statusLine). No absolute paths: check:no-abs-paths requires a
|
||||
# clone to work on any machine, and a status line is exactly the sort of dotfile that quietly
|
||||
# hardcodes someone's home directory.
|
||||
set -uo pipefail
|
||||
|
||||
IN=$(cat 2>/dev/null || true)
|
||||
# Claude Code hands the hook a JSON blob; workspace.current_dir is the directory the session is in.
|
||||
# Parsed with sed rather than a JSON tool because this runs on every render and must stay cheap.
|
||||
DIR=$(printf '%s' "$IN" | sed -n 's/.*"current_dir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
[ -z "$DIR" ] && DIR=$(printf '%s' "$IN" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
[ -z "$DIR" ] && DIR="$PWD"
|
||||
cd "$DIR" 2>/dev/null || true
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT=""
|
||||
if [ -n "$ROOT" ]; then cd "$ROOT" 2>/dev/null || true; fi
|
||||
# The repo NAME, not the checkout's directory name: every builder works in its own worktree
|
||||
# (wt-bundler-2, wt-bundler-1, ...), and printing the folder would label each agent with a name
|
||||
# nobody recognises instead of the project they are all in. The remote is the stable identity.
|
||||
REPO=$(basename -s .git "$(git config --get remote.origin.url 2>/dev/null)" 2>/dev/null)
|
||||
[ -z "$REPO" ] && REPO=$(basename "${ROOT:-$PWD}")
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||
|
||||
# WHAT IS BEING WORKED ON — uncommitted work first, because that is what "working on" means.
|
||||
# Ordered by mtime, not by git's alphabetical listing: with several bundles dirty, the one touched
|
||||
# most recently is the honest answer and the alphabetical first is an arbitrary one.
|
||||
DIRTY=$(git status --porcelain 2>/dev/null | sed 's/^...//' | sed 's/^.* -> //')
|
||||
BUNDLE=$(printf '%s\n' "$DIRTY" | grep '^plugin-designs/' 2>/dev/null | xargs -r ls -td 2>/dev/null | head -1 | cut -d/ -f2)
|
||||
|
||||
SCOPE=""
|
||||
if [ -z "$BUNDLE" ]; then
|
||||
# A library/ edit is not one plugin's business — it compiles into every bundle that imports it,
|
||||
# which is the blast radius AGENT-BRIEF makes you name before changing anything in there. Say so
|
||||
# rather than naming whichever bundle happens to be dirty for an unrelated reason.
|
||||
if printf '%s\n' "$DIRTY" | grep -q '^library/'; then
|
||||
SCOPE="library (reaches every bundle)"
|
||||
elif printf '%s\n' "$DIRTY" | grep -q '^internal/'; then
|
||||
SCOPE="internal (build + gates)"
|
||||
else
|
||||
# Nothing uncommitted: fall back to the last commit that touched a plugin, so a line still says
|
||||
# something true right after a commit instead of going blank at the moment work lands.
|
||||
# -m makes a MERGE commit list its files: without it a merge reports nothing changed, so the
|
||||
# line read "idle" immediately after landing work — the least accurate moment to say so. Look
|
||||
# back a few commits too, since the tip is often a merge or a build-output commit.
|
||||
BUNDLE=$(git log -6 --name-only --format= -m 2>/dev/null | grep -m1 '^plugin-designs/' | cut -d/ -f2)
|
||||
[ -n "$BUNDLE" ] && SCOPE="" || SCOPE="idle"
|
||||
fi
|
||||
fi
|
||||
|
||||
TIER=""
|
||||
if [ -n "$BUNDLE" ] && [ -f public/doxa-maps/manifest.json ]; then
|
||||
TIER=$(python3 - "$BUNDLE" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
# The studio's own table (plugin-studio.vue:293) — tier = 5 - PT[type]; unknown types are cap-1.
|
||||
PT = {'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4}
|
||||
try:
|
||||
m = json.load(open('public/doxa-maps/manifest.json'))
|
||||
want = sys.argv[1]
|
||||
caps = [5 - PT[p.get('type')] if p.get('type') in PT else 1
|
||||
for b in m.get('bundles', []) if b.get('bundle') == want
|
||||
for p in b.get('profiles', [])]
|
||||
# A bundle may ship several profiles at different tiers; the HEADLINE capability is the highest
|
||||
# one it demonstrates, which is how the studio's left rail groups it.
|
||||
if caps: print(max(caps))
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
)
|
||||
fi
|
||||
|
||||
OUT="$REPO"
|
||||
[ -n "$BRANCH" ] && OUT="$OUT · $BRANCH"
|
||||
if [ -n "$BUNDLE" ]; then
|
||||
if [ -n "$TIER" ]; then OUT="$OUT · ▶ $BUNDLE (cap-$TIER)"; else OUT="$OUT · ▶ $BUNDLE"; fi
|
||||
elif [ -n "$SCOPE" ]; then
|
||||
OUT="$OUT · ▶ $SCOPE"
|
||||
fi
|
||||
printf '%s\n' "$OUT"
|
||||
exit 0
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
# build.yml — the SINGLE-BUILDER CI gate for the doxa-maps bundler.
|
||||
#
|
||||
# WHY THIS EXISTS (IDEA-FORGEJO-CICD-BUILD-LOCK-MULTIWRITER):
|
||||
# Many agents edit + commit source to this repo in PARALLEL. That is safe. What is
|
||||
# NOT safe is many agents each running `bun run build` into the shared, committed
|
||||
# web-root (public/): concurrent builds clobber each other's output and race the
|
||||
# `git add public/` that follows. internal/build-lock.sh makes that STRUCTURALLY
|
||||
# impossible on ONE machine (an flock mutex); this workflow is DESIGNED to do the
|
||||
# same at CLUSTER scope — to make CI the ONE-AND-ONLY builder, and serialize it.
|
||||
#
|
||||
# ★ STATUS: DESIGNED, NOT YET ACTIVE — measured 2026-09-22, not assumed.
|
||||
# The forge reports has_actions=true, but
|
||||
# GET /api/v1/repos/doxa-life/doxa-maps/actions/tasks -> total_count: 0
|
||||
# Across 288 commits (138 of them pushed on this branch) this workflow has NEVER
|
||||
# RUN. The cluster-scope serialization described above is therefore NOT in force
|
||||
# today: every real build is serialized only by the local flock in
|
||||
# internal/build-lock.sh. Do not rely on this file to prevent a concurrent-build
|
||||
# race until a runner is registered AND a run is observed in the Actions tab.
|
||||
# RE-VERIFIED 2026-09-24: still total_count 0. The 0-runs figure is the
|
||||
# load-bearing fact and it holds — pushes with a working runner would have
|
||||
# produced runs.
|
||||
# ⚠ CORRECTION to what this header used to say. It claimed "the actions/runners
|
||||
# endpoints 404 on this Forgejo version, so runner state was not directly
|
||||
# measurable". Only the LISTING endpoint 404s. Both registration-token endpoints
|
||||
# return 200 and issue a 40-char token on demand (measured 2026-09-24):
|
||||
# /api/v1/repos/doxa-life/doxa-maps/actions/runners/registration-token
|
||||
# /api/v1/admin/runners/registration-token
|
||||
# So registering a runner is API-reachable, not a blocked unknown — which matters,
|
||||
# because "not measurable" reads as "nothing to try" and that is why this sat.
|
||||
# The step-by-step is in the handoff doc cited below, which EXISTS as of
|
||||
# 2026-09-24: it was referenced twice here and had never been written.
|
||||
#
|
||||
# THE CONTRACT for a swarm agent:
|
||||
# edit files -> git commit -> git push (DONE. never run `bun run build`.)
|
||||
# The push lands here. This job — and ONLY this job — runs the authoritative build
|
||||
# and commits the regenerated public/ back. See the handoff doc:
|
||||
# _qa-cycle/03-deployment-cycle/delegation/HANDOFF-forgejo-cicd-buildgate.md
|
||||
#
|
||||
# NOTE ON PATH: Forgejo reads .forgejo/workflows/ (and .gitea/workflows/), NOT
|
||||
# .github/workflows/. The existing .github/workflows/pages.yml is a GitHub-Pages
|
||||
# workflow and never runs here; this is its Forgejo graduation.
|
||||
|
||||
name: build-gate
|
||||
|
||||
# Trigger on every push to the source branches. workflow_dispatch lets the Driver
|
||||
# run it by hand from the Forgejo UI.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- doxa-maps-plugin-studio
|
||||
# Source-only edits trigger a build. Skip pushes that touch NOTHING but the
|
||||
# build output or CI itself — the build-output commit this job makes would
|
||||
# otherwise re-trigger the job in a loop.
|
||||
paths-ignore:
|
||||
- 'public/**'
|
||||
- '.forgejo/**'
|
||||
workflow_dispatch:
|
||||
|
||||
# THE HEART OF THE GATE. All builds share ONE concurrency group, so Forgejo runs
|
||||
# at most one at a time. A push that arrives while a build is running is QUEUED,
|
||||
# not cancelled and not failed — cancel-in-progress is FALSE on purpose: we want
|
||||
# every push's source to be built in order, never dropped. This is the flock
|
||||
# nonblock/queue behaviour of build-lock.sh, expressed as a scheduler primitive.
|
||||
concurrency:
|
||||
group: doxa-maps-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Requires a registered Forgejo Actions runner labelled `ubuntu-latest`
|
||||
# (docker or host). If none is registered the job stays queued — see the
|
||||
# handoff doc for the ONE Driver action that registers it.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# The job needs to read the repo AND commit the regenerated public/ back, so
|
||||
# it needs write. The runner's automatic token carries the repo's scope.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout the pushed source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so the build-output commit is a normal fast-forward.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install bun
|
||||
uses: https://github.com/oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies (locked)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# ★ BUILD ONE PACKET BEFORE THE GATES, so check:packets-all has something to check.
|
||||
# library/scripts/posters/out/ is gitignored — both the records cache and every
|
||||
# generated packet — so a clean CI checkout had NONE, that sweep SKIPped on every
|
||||
# single build, and every packet assertion in this tree (the 12 prayer points, the
|
||||
# wheel's 12 labels, page 1's geography, the print layer) was never once evaluated
|
||||
# here. A SKIP that is always taken is indistinguishable from an unwired gate.
|
||||
# It builds from a 20KB COMMITTED fixture: no API, no network, no photo fetch. The
|
||||
# packet stamps itself "fixture nomap" (CI has no poster sources either), which the
|
||||
# gate reads and accepts as declared — while still asserting everything that does
|
||||
# not depend on live data. It runs BEFORE build:unlocked because gates:post is
|
||||
# inside that script.
|
||||
- name: Build one packet from the committed fixture
|
||||
run: bun run packet:ci
|
||||
|
||||
|
||||
# THE AUTHORITATIVE BUILD. build:unlocked is the real chain (clean -> token
|
||||
# -> per-bundle vite -> tree -> drop/parse checks -> verify -> gallery). We
|
||||
# call :unlocked, NOT `bun run build`, because CI IS the single builder now —
|
||||
# the local flock mutex would only get in its own way here. The MAPBOX_TOKEN
|
||||
# secret becomes public/api/maps/token.json (gitignored) so the credential
|
||||
# reaches the built site without ever entering git history.
|
||||
- name: Build (authoritative, single-writer)
|
||||
env:
|
||||
MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }}
|
||||
VITE_MAPBOX_TOKEN: ${{ secrets.MAPBOX_TOKEN }}
|
||||
BUILD_LOCK_OWNER: forgejo-ci
|
||||
run: bun run build:unlocked
|
||||
|
||||
# Guards graduated from pages.yml: never publish a broken or credential-
|
||||
# leaking web-root. A failure here fails the build — nothing is committed.
|
||||
# ★ ASSERT THE PATH THE CLIENT ACTUALLY REQUESTS, NOT JUST THE TYPED ONE (2026-09-21).
|
||||
# This guarded only token.json, while every generated leaf requests the EXTENSIONLESS
|
||||
# '/api/maps/token' (grep a built leaf: it appears four times, '.json' never). A static host
|
||||
# returns 404 for the extensionless path and 200 for token.json, so a regression that stopped
|
||||
# writing the bare file would have shipped SILENTLY PAST CI with the endpoint "asserted".
|
||||
# write-token.mjs emits BOTH; guard BOTH. The bare one is a BARE TOKEN STRING, not JSON — the
|
||||
# client uses the response verbatim and rejects {"token":"pk..."} as lacking a pk. prefix.
|
||||
- name: Assert the token endpoint exists
|
||||
run: |
|
||||
test -s public/api/maps/token || {
|
||||
echo "::error::public/api/maps/token is missing/empty — this is the path the CLIENT requests; every basemap renders blank."
|
||||
exit 1
|
||||
}
|
||||
test -s public/api/maps/token.json || {
|
||||
echo "::error::public/api/maps/token.json is missing/empty — set the MAPBOX_TOKEN secret, or every basemap renders blank."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ★ SCREENSHOT FRESHNESS — the one standalone gate that BELONGS here (2026-09-21).
|
||||
# A stale README screenshot looks EXACTLY like a current one: no error, no 404, nothing fails.
|
||||
# The 12 PNGs silently drifted a day behind the UI and only a manual mtime comparison caught it.
|
||||
# This is a pure file-stat check (no browser needed), and CI has just rebuilt every bundle, so a
|
||||
# PNG older than its bundle here is real staleness — not a build-order artifact.
|
||||
# NOTE it REPORTS rather than blocks: CI has no browser, so it cannot run readme-shots.mjs to
|
||||
# fix what it finds. Failing the build over something CI is structurally unable to repair would
|
||||
# only teach everyone to ignore a red build. `bun run shots` locally is the fix.
|
||||
# ★ WORDPRESS EMBED CONTRACT — deployment method 4 of the 5 the hand-off promises.
|
||||
# The plugin is hand-written PHP but reads manifest.json at RUNTIME, so it can drift from a
|
||||
# CONTRACT rather than from a file: rename a manifest field and the shortcode silently emits
|
||||
# broken markup. deployment/wordpress/tests/ stubs ~20 WP functions and renders the REAL shortcode against
|
||||
# the REAL built manifest (with a negative test that a malformed integrity is rejected) — and it
|
||||
# ran NOWHERE automatic until now.
|
||||
# SELF-DETECTING on purpose: ubuntu-latest usually ships php, but asserting that would make this
|
||||
# step fail for a reason unrelated to the contract. If php is absent we SAY SO rather than
|
||||
# silently skipping — an unrun check must be visible, not invisible.
|
||||
# ★ LIBRARY MENU FRESHNESS — CONTENT-based, deliberately NOT mtime-based (2026-09-21).
|
||||
# documentation/reference-library-index.md is DERIVED from library/ and its own header promises it
|
||||
# "can't drift from the code" — a promise that had NO mechanism: nothing invoked the generator
|
||||
# and the file did not exist at all. The local gate (check:library-index) compares mtimes, but
|
||||
# ★ THAT CANNOT WORK HERE: a fresh checkout gives every file the SAME mtime, so an mtime gate
|
||||
# would ALWAYS pass in CI — decoration. Instead REGENERATE and diff: the generator is
|
||||
# deterministic (verified: two runs byte-identical), so any diff means the committed index is
|
||||
# stale. Reports rather than blocks, and restores the file so the publish step is unaffected.
|
||||
- name: Report a stale library menu
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cp documentation/reference-library-index.md /tmp/idx.before
|
||||
bun run library-index >/dev/null
|
||||
if ! diff -q /tmp/idx.before documentation/reference-library-index.md >/dev/null; then
|
||||
echo "::warning::documentation/reference-library-index.md is STALE — run 'bun run library-index' and commit."
|
||||
diff /tmp/idx.before documentation/reference-library-index.md | head -20 || true
|
||||
else
|
||||
echo "library menu is current."
|
||||
fi
|
||||
cp /tmp/idx.before documentation/reference-library-index.md
|
||||
|
||||
- name: Verify the WordPress embed contract
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
bun run check:wp-embed
|
||||
else
|
||||
echo "::warning::php not on this runner — WordPress embed contract NOT verified this run."
|
||||
fi
|
||||
|
||||
- name: Report stale README screenshots
|
||||
continue-on-error: true
|
||||
run: bun run check:shots
|
||||
|
||||
# BLOCKING, unlike the three report-only steps above: this compares two files that
|
||||
# are both already in the checkout, needs no network, no browser and no toolchain,
|
||||
# and is fully deterministic — so a failure is always a real defect, never runner
|
||||
# flake. The doc it guards QUOTES package.json verbatim and had silently drifted
|
||||
# TWICE, most recently hiding check:public-hygiene (the secret scan) from the
|
||||
# build chain a reader believes they are auditing.
|
||||
- name: Assert the docs' quoted scripts still match package.json
|
||||
run: bun run check:quoted-scripts
|
||||
|
||||
# Also BLOCKING and also browser-free: this parses readme-shots.mjs and compares declared
|
||||
# (url + viewport) pairs. It caught the README shipping "List — page 1" and "page 2" as the
|
||||
# same picture, which no checksum could see (0.16% render-noise difference).
|
||||
- name: Assert no two README slots capture the same view
|
||||
run: bun run check:shot-distinctness
|
||||
|
||||
# Blocking and browser-free: compares README capability claims against manifest.json, which
|
||||
# the build just produced. Caught the README calling doxa-vision-card a cap-1 single element
|
||||
# when its instances.json makes it cap-2 — an error already fixed in verify-5-capabilities.mjs
|
||||
# that never reached the README.
|
||||
- name: Assert README capability citations match the manifest
|
||||
run: bun run check:capability-citations
|
||||
|
||||
# Regenerates the derived exemplar table and content-compares it. Blocking: the build just
|
||||
# produced the manifest it derives from, so a mismatch is always real. Content-compare, not
|
||||
# mtime — a full build rewrites manifest.json every time.
|
||||
- name: Assert the capability exemplar table matches the manifest
|
||||
run: bun run check:capability-exemplars
|
||||
|
||||
- name: Assert no local credential is being published
|
||||
run: |
|
||||
if [ -e public/token.local.js ]; then
|
||||
echo "::error::public/token.local.js is present in the web-root and must not be committed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Commit the regenerated public/ back to the branch. This is the ONE writer
|
||||
# of build output in the whole system. If the build produced no change,
|
||||
# commit nothing (idempotent). The token endpoint is gitignored, so it is
|
||||
# never staged. The [skip ci]-equivalent is handled by the paths-ignore
|
||||
# above: a public/-only commit does not re-trigger this job.
|
||||
- name: Publish the built web-root
|
||||
run: |
|
||||
git config user.name "forgejo-ci"
|
||||
git config user.email "ci@doxa.life"
|
||||
git add public/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No build-output change — nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "build: regenerate public/ from ${GITHUB_SHA:-source}"
|
||||
# Push back to the branch that triggered us. The runner token authorizes
|
||||
# this; contents:write is granted above.
|
||||
git push origin "HEAD:${GITHUB_REF_NAME}"
|
||||
223
AGENT-BRIEF.md
223
AGENT-BRIEF.md
|
|
@ -1,223 +0,0 @@
|
|||
# AGENT BRIEF — read this once, then build
|
||||
|
||||
> **If you were dispatched to this repo, read this file first.** It replaces the wall of environment
|
||||
> rules that used to be pasted into every spawn prompt. Your prompt should now be short: what to
|
||||
> build, which files you own, and the one assertion that would prove it.
|
||||
>
|
||||
> **Why this file exists:** measured 2026-09-23 — agents produced 21 report docs totalling 2,079
|
||||
> lines and 27 scratch probes against a handful of product commits, because each spawn prompt made
|
||||
> *proving* the job and *building* the side effect. Every agent also re-derived the same environment
|
||||
> from scratch. That discovery tax is now paid once, here.
|
||||
|
||||
## The rule that matters most
|
||||
|
||||
**Ship a commit, then write two sentences. Not the reverse.** The artifact is the diff. A long,
|
||||
honest report attached to no commit is the failure mode this file exists to end.
|
||||
|
||||
**Land it on `main`.** A local commit is invisible — the Driver watches **`main`** on the Forgejo
|
||||
repo, so work that stops at `git commit`, or at a side branch, is not delivered. Work in your OWN
|
||||
worktree off `main`, then land on `main` through the **merge queue** (`main-capture`). Pushes to `main`
|
||||
without it are REJECTED by the server: several agents share one Forgejo account, so the lock is the
|
||||
only thing that serializes them.
|
||||
|
||||
```sh
|
||||
# Credentials: route git through the host's askpass helper. NEVER put a token in a URL, a
|
||||
# command line, or .git/config, and never let git prompt (a prompt hangs a headless agent).
|
||||
export GIT_ASKPASS="$(dirname "$(command -v main-capture)")/forgejo-askpass"
|
||||
export FORGEJO_PASS_ENTRY=vivarium/forgejo-certified-sandbox GIT_TERMINAL_PROMPT=0
|
||||
unset SSH_ASKPASS DISPLAY
|
||||
|
||||
git fetch origin && git rebase origin/main # you resolve every conflict, not main
|
||||
# ...UNLESS this branch has ever recorded a
|
||||
# `-s ours` merge — then MERGE, never rebase.
|
||||
# See "Never rebase a branch that has ever
|
||||
# recorded a `-s ours` merge" below; landing by
|
||||
# rebase and reconciling the old tip with
|
||||
# `-s ours` is what puts one there, so the very
|
||||
# next landing on the same branch is the one
|
||||
# this catches. `git log --merges` tells you.
|
||||
|
||||
if main-capture capture --wait; then # --wait retries with backoff while held;
|
||||
# release ONLY a lock you actually took
|
||||
main-capture scan && git push origin HEAD:main
|
||||
main-capture release # ...but ALWAYS release one you took
|
||||
fi
|
||||
```
|
||||
|
||||
**Release is not owner-checked: it deletes whoever's lock is there.** So the two rules pull in
|
||||
opposite directions and the `if` satisfies both. The older one-liner `capture && scan && push ;
|
||||
release` runs release even when capture FAILED because someone else held the lock — and freed
|
||||
THEIR lock mid-landing (measured 2026-09-24). Never pipe `capture` into `tail`/`grep` either: the
|
||||
`if` would then test the filter's exit code, not the capture's.
|
||||
|
||||
Grade the push by its EXIT CODE (`${PIPESTATUS[0]}` if you pipe it), never by grepping its output:
|
||||
the word `main` also appears in the rejection line, and a grep once reported 8 rejected pushes as
|
||||
landed. After pushing, `git fetch origin` and confirm your commit is on `origin/main`.
|
||||
|
||||
Only push to `main` what you have actually verified — it is the branch a human is looking at. The
|
||||
old `doxa-maps-plugin-studio` branch is retired; do not push to it.
|
||||
|
||||
## Do not run the full build
|
||||
|
||||
`bun run build` takes minutes, and its first step deletes `public/doxa-maps/` — which wipes the
|
||||
output every other agent is using. **Use the dev server; it hot-reloads.**
|
||||
|
||||
**This is now ENFORCED, not just asked.** `bun run build` refuses with exit 75 unless you set
|
||||
`BUILD_ALLOW=1`. If you hit that refusal it is telling you the truth — take one of the cheaper
|
||||
paths it prints rather than reaching for the flag. Only the coordinator, running the single
|
||||
authoritative build for a wave, should set it. This rule was documented here *before* it was
|
||||
enforced and got violated anyway, by a dispatch prompt that said `bun run build` while telling the
|
||||
agent to read this file first. An explicit instruction beats a standing rule at the point of work,
|
||||
so the build now refuses instead of asking.
|
||||
|
||||
```sh
|
||||
bun x vite --port <YOUR OWN PORT> --strictPort # e.g. 5299, 5399, 5499 …
|
||||
BUNDLE=<name> bun x vite build # one bundle's compiled output; does NOT clean
|
||||
```
|
||||
|
||||
`--strictPort` is **mandatory**. Vite silently moves to the next free port otherwise, and five gates
|
||||
have `5173` hardcoded (`check-facet-changes-map`, `plugin-gallery-smoke`, `smoke-studio`,
|
||||
`generate-gallery`, `check-scrim-covers-viewport`) — so a drifted server makes a gate talk to
|
||||
somebody else's page and report a confident wrong answer with no error.
|
||||
|
||||
## Before you believe a measurement
|
||||
|
||||
| Symptom | Check this first |
|
||||
|---|---|
|
||||
| "it didn't mount" / "0 elements" | `bun run check:sri` — a single-bundle build leaves stale SRI in leaf pages and the browser then **refuses to execute the script**. Looks identical to a mount failure. |
|
||||
| bundles missing, files deleted, counts wrong | `bun run check:settled` — graded **by exit code**. It reads the build *lock*, which knows the *queue*; `pgrep` samples an instant and will tell you 0 builders while one is queued. |
|
||||
| a gate "failed" | Re-run it un-suppressed. A command killed by a short `timeout` is not a failure, and these gates print teaching prose *after* the verdict, so `| tail -1` inverts the signal. **Grade by exit code.** |
|
||||
| a selector finds nothing in a composite | Shadow DOM does not pierce. Use a recursive `shadowRoot` walker, not `document.querySelector`. |
|
||||
| a probe's verdict, in EITHER direction | **Read the selector and the denominator out of the source; never invent them.** An invented selector fails a working feature, and a count the page prints about itself passes a broken one. Measured three times in one flight: `.gm-sug` returned 0 for every search query while search worked fine; a chart was "wrong" only because the probe counted all 5,563 catalogue codes when the chart counts the 5,067 spoken by a pin; and a table probe read its own "234 rows" caption and passed an entirely empty body. Assert on rendered content — rows *and* cells — and grep the component for the real class names first. |
|
||||
|
||||
## Facts that are easy to get wrong
|
||||
|
||||
- **This box HAS working WebGL** via SwiftShader — `verify:map-paint` passes 11/11 with live GL at
|
||||
1280×800. Any instruction saying "this VPS has NO WebGL" is **stale**; you may assert painted
|
||||
canvases and real zoom. Launch flags are in `internal/verify-map-paint.mjs`.
|
||||
- **Playwright must run from inside this repo** — `node_modules` is here; a script in `/tmp` cannot
|
||||
resolve it.
|
||||
- **`serve-strict` takes `--directory`, not `--root`**, and returns **404 for a directory URL** by
|
||||
design (production parity). Ask for `/index.html` explicitly.
|
||||
- **`pray.doxa.life` needs a browser User-Agent** from scripts; a default script UA gets 403.
|
||||
- **`root-dx-life` is a READ-ONLY clone** and not even a git repo — zero edits, ever, no safety net.
|
||||
- **Scratch files go in `_qa-cycle/`**, never in this repo.
|
||||
|
||||
## House style
|
||||
|
||||
- **Material LIGHT only:** `#f3f3f1` surface · `#fff` · `#3b463d` text · `#92b195` accent ·
|
||||
`#d1d4cd` border · `#6d766e` muted. No dark mode, no `prefers-color-scheme: dark`.
|
||||
- **No backticks inside shadow-CSS template literals** — this broke the build twice. Use string
|
||||
concatenation: `'.x{display:block}' + '.y{…}'`.
|
||||
- Keep both synced CSS copies byte-identical (`check:synced-css`).
|
||||
- Every visible button needs an accessible name (`check:a11y-button-names`).
|
||||
- **Imitate before innovate.** Where DX-life's designer already made a decision — radii, spacing,
|
||||
type scale, colours — port it faithfully rather than substituting our tokens.
|
||||
- Commit messages are product-only: no AI or authorship trailers.
|
||||
|
||||
## How much verification is enough
|
||||
|
||||
**Size it to the blast radius.** This is a judgement, and over-verifying is a real cost, not a
|
||||
virtue:
|
||||
|
||||
| Change | Prove it with |
|
||||
|---|---|
|
||||
| one bundle's own CSS / copy | the dev server + a screenshot you actually read |
|
||||
| one bundle's logic | a unit-style assertion on the function, or one browser check |
|
||||
| anything in `library/` | name every consumer (`grep`) and re-check at least two of them — a change here reaches every bundle |
|
||||
| layout / scroll / wheel behaviour | the three-clause conjunction, via `bun run check:fits-screen` |
|
||||
|
||||
State the **one assertion that would falsify your change**, run it, and report the number. You do not
|
||||
owe a complete audit.
|
||||
|
||||
★ **Read the image when the deliverable is visual.** Numbers have certified visually broken output
|
||||
in this repo more than once — a green generator is not a look-at-it.
|
||||
|
||||
## A pipe hides a failure
|
||||
|
||||
A pipeline's exit status is its LAST command's. So this publishes a STALE bundle — new
|
||||
source, old `.js` — and says nothing:
|
||||
|
||||
```sh
|
||||
BUNDLE=x bun x vite build 2>&1 | tail -2 && bun run tree # tail succeeded, so && proceeds
|
||||
```
|
||||
|
||||
Measured on this machine, all three states:
|
||||
|
||||
| Form | On a failed build |
|
||||
|---|---|
|
||||
| `build 2>&1 \| tail -2 && next` | **reports success** and runs `next` |
|
||||
| `set -o pipefail` before it | stops, correctly |
|
||||
| `build >/dev/null 2>&1 && next` | stops, correctly — no pipe, so the status is the build's |
|
||||
|
||||
Every gate stays green afterwards, because `check:sri` proves the leaf and the bundle agree
|
||||
with **each other**, not with the source. The same shape bit a `git merge` in this repo:
|
||||
`if ! git merge … | tail` tested the pipe, so a conflicted merge read as clean.
|
||||
|
||||
**Rule:** never put a build or a merge on the left of a pipe you then branch on. Redirect
|
||||
instead of piping, or `set -o pipefail` first. If you want short output AND a reliable
|
||||
status, capture to a file and `tail` the file afterwards.
|
||||
|
||||
### And capture the status into a variable, not `$?` later
|
||||
|
||||
`$?` is a single slot that EVERY command overwrites — including the harmless one somebody
|
||||
adds months later for readability. Measured here:
|
||||
|
||||
```sh
|
||||
some_command > log 2>&1
|
||||
sed -n '1p' log # redact a token, rotate the log, echo a heading — anything
|
||||
if [ "$?" -ne 0 ]; then ... # tests SED, not some_command. A failure reads as success.
|
||||
```
|
||||
|
||||
The durable form names it on the very next line, and branches on the name:
|
||||
|
||||
```sh
|
||||
some_command > log 2>&1
|
||||
rc=$? # nothing may come between these two lines
|
||||
sed -n '1p' log # now insert whatever you like
|
||||
if [ "$rc" -ne 0 ]; then tail log; exit 1; fi
|
||||
```
|
||||
|
||||
Both were run: with the `sed` in between, the bare `$?` reported success on a command that
|
||||
had failed; `rc` still read 1. This is the same family as the pipe above — a status you read
|
||||
one step too late is a status about something else — and it matters most in exactly the
|
||||
scripts that redact a token before printing a log, because that redaction looks harmless.
|
||||
|
||||
## Landing on main: the shape of the lock
|
||||
|
||||
`main` is shared and serialized by `main-capture`. The ordering is load-bearing and BOTH
|
||||
obvious forms are wrong — each was tried, and each broke something:
|
||||
|
||||
| Form | What it does wrong |
|
||||
|---|---|
|
||||
| `capture ; scan && push ; release` | `;` runs `release` even when **capture failed** — so a non-owner frees the actual owner's lock. This happened: it dropped another agent's hold while they were mid-landing. |
|
||||
| `capture && scan && push && release` | `&&` skips `release` when **push fails** — the branch stays protected by a lock nobody will now drop, and everyone else is wedged. |
|
||||
|
||||
Release must be conditional on **your capture succeeding**, never on your push succeeding:
|
||||
|
||||
```sh
|
||||
main-capture capture --wait && { main-capture scan && git push origin HEAD:main ; main-capture release ; }
|
||||
# or, equivalently:
|
||||
if main-capture capture --wait; then main-capture scan && git push origin HEAD:main; main-capture release; fi
|
||||
```
|
||||
|
||||
Capture failed → `release` never runs, so you cannot free someone else's lock.
|
||||
Capture succeeded → `release` always runs, so a failed push cannot wedge the branch.
|
||||
|
||||
Two more rules that cost real work to learn:
|
||||
|
||||
- **Hold the lock THROUGH fetch-merge-push**, not just the push. Capturing, releasing, then
|
||||
merging means you merged against a `main` someone else has already moved.
|
||||
- **Never rebase a branch that has ever recorded a `-s ours` merge.** Replaying duplicates the
|
||||
recorded ancestry. Merge instead. On conflicts in generated `public/`, take `main`'s tree and
|
||||
regenerate from source rather than hand-resolving built bytes.
|
||||
|
||||
## Where the hard-won knowledge lives
|
||||
|
||||
Read these only when they bear on your task; do not read them all:
|
||||
|
||||
- `_qa-cycle/02-builder-cycle/lessons-learned.json` — stories with costs attached
|
||||
- `_qa-cycle/02-builder-cycle/anti-patterns.json` — smells to avoid
|
||||
- `_qa-cycle/02-builder-cycle/best-practices.json` — practices that proved themselves
|
||||
- `_qa-cycle/01-ideation-research/VISION-the-seven-plugins-are-one-argument.md` — what the product is
|
||||
*for*, and why the dashboard is deliberately shorter than a viewport
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · Embeddable JAMstack Maps · staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Mapbox peer loaded ONCE here; map custom elements mount in this page and use
|
||||
window.mapboxgl directly (same realm → no cross-realm container errors). -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787; --cy:#46d4ff; --vi:#b9a6f5; --gold:#ffc24b; --grn:#5ee0a0;
|
||||
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 480px at 82% -12%, rgba(70,212,255,.10), transparent 60%),
|
||||
radial-gradient(820px 460px at -8% 112%, rgba(185,166,245,.10), transparent 60%),
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); min-height: 100%; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code { font-family: var(--mono); background:#1f2937; padding:1px 5px; border-radius:4px; color:var(--gold); }
|
||||
|
||||
/* ── Architecture header: the single-build-process showcase ── */
|
||||
header.forge { padding: 22px 28px 6px; }
|
||||
header.forge h1 { font: 800 21px/1.2 var(--sans); letter-spacing:-.02em; }
|
||||
header.forge h1 .ac { color: var(--cy); }
|
||||
header.forge .sub { margin-top: 6px; font: 13px/1.55 var(--sans); color: var(--mut); max-width: 78ch; }
|
||||
header.forge .sub b { color: var(--ink); }
|
||||
|
||||
/* build pipeline: one shared core → build → bundles → element tags → maps */
|
||||
.pipe { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:14px; }
|
||||
.pstep { display:flex; flex-direction:column; gap:2px; background:var(--surf); border:1px solid var(--line);
|
||||
border-radius:10px; padding:9px 13px; min-width:120px; }
|
||||
.pstep .pt { font:700 12px/1.2 var(--sans); }
|
||||
.pstep .pd { font:10px/1.3 var(--mono); color:var(--mut); }
|
||||
.pipe .arr { color:var(--cy); font-weight:800; }
|
||||
#count { display:block; margin-top:10px; color:var(--cy); font:12px/1 var(--mono); }
|
||||
|
||||
/* ── Language bar ── */
|
||||
.langbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap;
|
||||
margin: 14px 28px 0; padding:11px 15px; background:var(--surf); border:1px solid var(--line);
|
||||
border-radius:10px; font:13px/1.4 var(--sans); color:var(--mut); }
|
||||
.langbar select { background:var(--surf2); color:var(--ink); border:1px solid #2b3648;
|
||||
border-radius:8px; padding:7px 10px; font:inherit; cursor:pointer; }
|
||||
.langbar select:hover { border-color: var(--cy); }
|
||||
|
||||
/* ── Horizontal JS-bundle strip (slim sticky bar; the page scrolls normally) ── */
|
||||
#bundleTabs { position: sticky; top: 0; z-index: 5; display:flex; gap:10px;
|
||||
padding: 12px 28px; overflow-x:auto; scrollbar-width:thin;
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
|
||||
border-bottom:1px solid var(--surf2); }
|
||||
#bundleTabs::-webkit-scrollbar { height:8px; }
|
||||
#bundleTabs::-webkit-scrollbar-thumb { background:#243349; border-radius:8px; }
|
||||
.btab { flex:0 0 auto; text-align:left; cursor:pointer; min-width:198px;
|
||||
background:var(--surf); border:1px solid var(--line); border-radius:12px; padding:11px 14px; color:inherit; transition:.15s; }
|
||||
.btab:hover { border-color:#33455f; transform:translateY(-1px); }
|
||||
.btab.active { border-color:var(--gem,var(--cy));
|
||||
background: color-mix(in srgb, var(--gem,var(--cy)) 12%, var(--surf));
|
||||
box-shadow: 0 0 0 1px var(--gem,var(--cy)) inset, 0 6px 20px rgba(0,0,0,.3); }
|
||||
.btab .bname { font:700 13px/1.1 var(--sans); display:flex; align-items:center; gap:8px; }
|
||||
.btab .dot { width:9px; height:9px; border-radius:50%; background:var(--gem,var(--cy)); box-shadow:0 0 8px var(--gem,var(--cy)); }
|
||||
.btab .btags { margin-top:6px; display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.btab .etag { font:9px/1 var(--mono); color:var(--vi); border:1px solid rgba(185,166,245,.4); border-radius:999px; padding:3px 7px; }
|
||||
.btab .bfile { margin-top:6px; font:10px/1 var(--mono); color:var(--dim); }
|
||||
|
||||
/* ── Live showcase — gem frames, full document scroll (NOTHING frozen over them) ── */
|
||||
#showcase { display:flex; flex-direction:column; gap:18px; padding: 18px 28px 28px; }
|
||||
.empty { color:var(--dim); font:13px/1.6 var(--mono); padding: 30px 0; }
|
||||
.tile { --gem:var(--cy); --gem-rgb:70,212,255; position:relative; display:flex; flex-direction:column;
|
||||
height:82vh; min-height:480px; padding:10px; background:var(--surf);
|
||||
border:1px solid var(--gem); border-radius:14px; box-shadow:0 0 26px rgba(var(--gem-rgb),.14); }
|
||||
.tile-header { display:flex; align-items:center; gap:10px; padding:7px 11px 11px; }
|
||||
.tile-header .sctag { font:800 14px/1 var(--mono); color:var(--gem); }
|
||||
.tile-header .sckind { font:9px/1 var(--mono); color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:3px 8px; }
|
||||
.tile-header .idx { margin-left:auto; font:10px/1 var(--mono); color:var(--dim); }
|
||||
.map-slot { flex:1 1 0; min-height:0; position:relative; border-radius:10px; border:1px solid var(--gem); background:#0d1622; overflow:hidden; }
|
||||
/* the mounted map — custom element OR iframe — fills the gem frame */
|
||||
.map-slot > iframe, .map-slot > :not(.tile-loading):not(.tile-error) {
|
||||
position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }
|
||||
.map-slot .tile-loading { position:absolute; inset:0; display:grid; place-items:center; color:var(--dim); font:12px/1 var(--mono); }
|
||||
.map-slot .tile-error { position:absolute; inset:0; display:grid; place-items:center; padding:16px; text-align:center; color:#f87171; font:12px/1.5 var(--mono); }
|
||||
|
||||
.foot { padding:14px 28px 20px; font:11.5px/1.5 var(--mono); color:var(--dim); border-top:1px solid var(--surf2); }
|
||||
.foot .scr { color: var(--vi); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="forge">
|
||||
<h1>doxa-maps · <span class="ac">Embeddable JAMstack Maps</span> · staging</h1>
|
||||
<p class="sub"><b>One shared core, one build, many paste-anywhere maps.</b> The shared library
|
||||
in <code>library/</code> is built once into a self-contained <code><name>.js</code> bundle per
|
||||
app-profile; each bundle registers its own custom-element tag(s). Scroll the strip to see every
|
||||
JS bundle and the element tags it ships; click one to load its maps as live gem frames below.</p>
|
||||
<div class="pipe">
|
||||
<div class="pstep"><span class="pt">library/</span><span class="pd">one shared core</span></div>
|
||||
<span class="arr">→</span>
|
||||
<div class="pstep"><span class="pt">npm run build</span><span class="pd">Vite multi-entry</span></div>
|
||||
<span class="arr">→</span>
|
||||
<div class="pstep"><span class="pt">JS bundles</span><span class="pd">one .js per profile</span></div>
|
||||
<span class="arr">→</span>
|
||||
<div class="pstep"><span class="pt">element tags</span><span class="pd"><custom-element></span></div>
|
||||
<span class="arr">→</span>
|
||||
<div class="pstep"><span class="pt">live maps</span><span class="pd">gem frames below</span></div>
|
||||
</div>
|
||||
<span id="count">detecting…</span>
|
||||
</header>
|
||||
|
||||
<div id="langbar" class="langbar"></div>
|
||||
<nav id="bundleTabs" aria-label="JS bundles"></nav>
|
||||
<main id="showcase"></main>
|
||||
|
||||
<div class="foot">
|
||||
Live proof of the bundler · bundles auto-detected from <code>plugin-designs/*/</code> · maps lazy-mounted, WebGL-context-safe.
|
||||
<span class="scr">“Write the vision, and make it plain … that he may run that readeth it.” — Habakkuk 2:2</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/internal/staging.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
// staging.js — doxa-maps "doxa-maps" staging showcase (dev only, no build step).
|
||||
//
|
||||
// UX: the header showcases the single-build-process architecture (one
|
||||
// shared core → one build → JS bundles → element tags → maps). A horizontal-scroll strip lists
|
||||
// every JS bundle and the element tags it ships. Click a bundle → its profiles render as live
|
||||
// GEM-FRAME maps below, and the WHOLE PAGE scrolls (nothing frozen over the maps).
|
||||
//
|
||||
// MAP MOUNT: the map's custom element is mounted DIRECTLY in this page (no iframe). index.html
|
||||
// loads the Mapbox peer once; importing a bundle's index.js registers its custom element, then we
|
||||
// drop a <tag profile-config> into the gem frame. (We do NOT iframe the map: Mapbox checks the
|
||||
// map container with `instanceof HTMLElement`, which fails across realms — an iframe's element is
|
||||
// not the parent realm's HTMLElement — so an iframe + reused parent Mapbox throws "container must
|
||||
// be a String or HTMLElement". Same-page mounting keeps one realm and Just Works.)
|
||||
//
|
||||
// WebGL guard: tiles start as placeholders; an IntersectionObserver mounts a tile's map only when
|
||||
// it scrolls into view and tears it down when it leaves; a hard MAX_LIVE cap LRU-evicts the
|
||||
// oldest off-screen map; on eviction we force-lose each canvas's GL context (browsers cap WebGL
|
||||
// contexts at ~8–16).
|
||||
//
|
||||
// LOCALIZATION: the language switch lives at the TOP (not on the maps). It sets the chosen
|
||||
// language on every map as the `locale` PROP (profile-config.locale) and re-mounts.
|
||||
|
||||
// ── Discovery ───────────────────────────────────────────────────────────────────────────────
|
||||
const indexRaw = import.meta.glob('/plugin-designs/*/index.js', { query: '?raw', import: 'default', eager: true })
|
||||
const htmlRaw = import.meta.glob('/plugin-designs/*/index.html', { query: '?raw', import: 'default', eager: true })
|
||||
const bundleLoaders = import.meta.glob('/plugin-designs/*/index.js') // lazy: importing registers the element
|
||||
const profileFiles = import.meta.glob('/plugin-designs/*/*.vue')
|
||||
|
||||
const TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || ''
|
||||
const nameOf = (p) => p.split('/plugin-designs/')[1].split('/')[0]
|
||||
|
||||
function tagsOf(src) {
|
||||
const re = /customElements\.define\(\s*['"]([^'"]+)['"]/g
|
||||
const out = []; let m
|
||||
while ((m = re.exec(typeof src === 'string' ? src : ''))) out.push(m[1])
|
||||
return out
|
||||
}
|
||||
function discover() {
|
||||
const byBundle = {}
|
||||
for (const [path, src] of Object.entries(indexRaw)) {
|
||||
const name = nameOf(path)
|
||||
byBundle[name] = { name, tags: tagsOf(src), tag: tagsOf(src)[0] || '', profiles: [], html: '' }
|
||||
}
|
||||
for (const [path, html] of Object.entries(htmlRaw)) {
|
||||
const name = nameOf(path)
|
||||
;(byBundle[name] = byBundle[name] || { name, tags: [], tag: '', profiles: [], html: '' }).html = (typeof html === 'string' ? html : '')
|
||||
}
|
||||
for (const path of Object.keys(profileFiles)) {
|
||||
const name = nameOf(path)
|
||||
const file = path.split('/').pop().replace(/\.vue$/, '')
|
||||
;(byBundle[name] = byBundle[name] || { name, tags: [], tag: '', profiles: [], html: '' }).profiles.push(file)
|
||||
}
|
||||
return Object.values(byBundle)
|
||||
.map((b) => ({ ...b, profiles: b.profiles.sort() }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// ── Slots: how many distinct MAPS a bundle exposes ──────────────────────────────────────────
|
||||
// A bundle's index.html declares its maps as `window.DOXA_SLOTS` (or static profile-config
|
||||
// attrs). Parameterized bundles (e.g. doxa-simple-map) list ONE profile with several configs —
|
||||
// Prayer / Adoption / Engagement — each a distinct map. Other bundles list several dev copies
|
||||
// of the SAME map that differ only by instanceId (e.g. doxa-research-map's 3 demo instances) —
|
||||
// those collapse to one. We parse the slots, then dedupe by config-minus-instanceId so distinct
|
||||
// parameterizations are kept but duplicate instances are not.
|
||||
function matchBracket(s, start) {
|
||||
let depth = 0, inStr = null
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const c = s[i]
|
||||
if (inStr) { if (c === inStr && s[i - 1] !== '\\') inStr = null; continue }
|
||||
if (c === "'" || c === '"' || c === '`') { inStr = c; continue }
|
||||
if (c === '[') depth++; else if (c === ']') { depth--; if (depth === 0) return s.slice(start, i + 1) }
|
||||
}
|
||||
return s.slice(start)
|
||||
}
|
||||
function rawSlots(b) {
|
||||
const html = b.html || ''
|
||||
const m = html.match(/window\.\w*SLOTS\s*=\s*\[/)
|
||||
if (m) {
|
||||
try {
|
||||
const arr = (new Function('return (' + matchBracket(html, html.indexOf('[', m.index)) + ')'))()
|
||||
if (Array.isArray(arr) && arr.length) return arr.map((s) => ({ profile: s.profile, label: s.label || s.profile, config: s.config || {} }))
|
||||
} catch (_) {}
|
||||
}
|
||||
const out = []
|
||||
const re = /profile-config\s*=\s*'([^']*)'/gi; let mm
|
||||
while ((mm = re.exec(html))) { try { const c = JSON.parse(mm[1]); if (c && c.profile) out.push({ profile: c.profile, label: c.profile, config: c }) } catch (_) {} }
|
||||
if (out.length) return out
|
||||
return (b.profiles.length ? b.profiles : ['(default)']).map((p) => ({ profile: p, label: p, config: {} }))
|
||||
}
|
||||
function slotsFor(b) {
|
||||
const seen = new Set(), out = []
|
||||
for (const s of rawSlots(b)) {
|
||||
const { instanceId, ...rest } = s.config || {} // ignore instanceId when judging "distinct"
|
||||
const sig = s.profile + '|' + JSON.stringify(rest)
|
||||
if (seen.has(sig)) continue
|
||||
seen.add(sig); out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Import (once, cached) a bundle's index.js so its custom element is registered.
|
||||
const imported = {}
|
||||
function loadBundle(name) {
|
||||
if (!imported[name]) {
|
||||
const key = Object.keys(bundleLoaders).find((k) => k.includes('/plugin-designs/' + name + '/index.js'))
|
||||
imported[name] = key ? bundleLoaders[key]() : Promise.reject(new Error('bundle loader not found: ' + name))
|
||||
}
|
||||
return imported[name]
|
||||
}
|
||||
|
||||
// ── Language ──────────────────────────────────────────────────────────────────────────────────
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', name: 'English' }, { code: 'es', name: 'Español' }, { code: 'fr', name: 'Français' },
|
||||
{ code: 'pt', name: 'Português' }, { code: 'ar', name: 'العربية' }, { code: 'ru', name: 'Русский' }
|
||||
]
|
||||
const LOCALE_KEY = 'doxa-maps:staging:locale'
|
||||
const ACTIVE_KEY = 'doxa-maps:staging:bundle'
|
||||
let currentLocale = localStorage.getItem(LOCALE_KEY) || 'en'
|
||||
let activeBundle = localStorage.getItem(ACTIVE_KEY) || ''
|
||||
|
||||
const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399']
|
||||
const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) }
|
||||
|
||||
function slotConfig(slot) {
|
||||
const cfg = Object.assign({ profile: slot.profile, tk: TOKEN, dataSource: 'pray-tools' }, slot.config || {})
|
||||
if (!cfg.instanceId) cfg.instanceId = 'stg-' + slot.profile + '-' + String(slot.label || '').replace(/\W+/g, '')
|
||||
if (currentLocale && currentLocale !== 'en') cfg.locale = currentLocale
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ── WebGL guard ───────────────────────────────────────────────────────────────────────────────
|
||||
const MAX_LIVE = 4
|
||||
const liveTiles = []
|
||||
function loseGL(root) {
|
||||
if (!root) return
|
||||
root.querySelectorAll('canvas').forEach((c) => {
|
||||
for (const t of ['webgl2', 'webgl', 'experimental-webgl']) {
|
||||
let gl; try { gl = c.getContext(t) } catch (_) { gl = null }
|
||||
if (gl) { const e = gl.getExtension('WEBGL_lose_context'); if (e) { try { e.loseContext() } catch (_) {} } }
|
||||
}
|
||||
})
|
||||
}
|
||||
function evictSlot(slot) {
|
||||
if (!slot || !slot.__mounted) return
|
||||
const el = slot.firstElementChild
|
||||
if (el && el.shadowRoot) loseGL(el.shadowRoot)
|
||||
slot.innerHTML = '<div class="tile-loading">map paused — scroll to view</div>'
|
||||
slot.__mounted = false
|
||||
const i = liveTiles.indexOf(slot); if (i !== -1) liveTiles.splice(i, 1)
|
||||
}
|
||||
async function mountSlot(slot) {
|
||||
if (!slot || slot.__mounted || !slot.__mountFn) return
|
||||
while (liveTiles.length >= MAX_LIVE) {
|
||||
const victim = liveTiles.find((s) => !s.__visible && s !== slot) || liveTiles.find((s) => s !== slot)
|
||||
if (!victim) break
|
||||
evictSlot(victim)
|
||||
}
|
||||
slot.__mounted = true
|
||||
liveTiles.push(slot)
|
||||
try {
|
||||
const el = await slot.__mountFn()
|
||||
if (!slot.__mounted) return // evicted while the bundle was importing
|
||||
slot.innerHTML = ''; slot.appendChild(el)
|
||||
} catch (e) {
|
||||
slot.__mounted = false
|
||||
const i = liveTiles.indexOf(slot); if (i !== -1) liveTiles.splice(i, 1)
|
||||
slot.innerHTML = '<div class="tile-error">⚠ ' + (e && e.message ? e.message : String(e)) + '</div>'
|
||||
}
|
||||
}
|
||||
const observer = ('IntersectionObserver' in window)
|
||||
? new IntersectionObserver((entries) => entries.forEach((en) => {
|
||||
en.target.__visible = en.isIntersecting
|
||||
if (en.isIntersecting) mountSlot(en.target); else evictSlot(en.target)
|
||||
}), { rootMargin: '400px 0px', threshold: 0.01 })
|
||||
: null
|
||||
|
||||
// ── Tiles + chrome ──────────────────────────────────────────────────────────────────────────
|
||||
function buildTile(b, sl, gem) {
|
||||
const tile = document.createElement('section')
|
||||
tile.className = 'tile'
|
||||
tile.style.setProperty('--gem', gem); tile.style.setProperty('--gem-rgb', hexRgb(gem))
|
||||
const showLabel = sl.label && sl.label !== sl.profile
|
||||
tile.innerHTML =
|
||||
'<div class="tile-header"><span class="sctag"><' + (b.tag || '??') + '></span>'
|
||||
+ '<span class="sckind">' + (showLabel ? sl.label : 'profile = "' + sl.profile + '"') + '</span>'
|
||||
+ '<span class="idx">' + b.name + '.js' + (currentLocale !== 'en' ? ' · ' + currentLocale : '') + '</span></div>'
|
||||
+ '<div class="map-slot"><div class="tile-loading">scroll to load…</div></div>'
|
||||
const slot = tile.querySelector('.map-slot')
|
||||
if (!b.tag) { slot.innerHTML = '<div class="tile-error">⚠ no custom-element tag found in index.js</div>'; return tile }
|
||||
slot.__mountFn = async () => {
|
||||
await loadBundle(b.name) // register the custom element (once, cached)
|
||||
const el = document.createElement(b.tag)
|
||||
el.setAttribute('profile-config', JSON.stringify(slotConfig(sl)))
|
||||
return el
|
||||
}
|
||||
if (observer) observer.observe(slot); else mountSlot(slot)
|
||||
return tile
|
||||
}
|
||||
function teardown() {
|
||||
liveTiles.slice().forEach(evictSlot); liveTiles.length = 0
|
||||
const sc = document.getElementById('showcase')
|
||||
if (observer) sc.querySelectorAll('.map-slot').forEach((s) => { try { observer.unobserve(s) } catch (_) {} })
|
||||
sc.innerHTML = ''
|
||||
}
|
||||
|
||||
function renderLangBar() {
|
||||
const bar = document.getElementById('langbar'); if (!bar) return
|
||||
bar.innerHTML = '<label for="lang-select">Language — passed to every map as a <code>locale</code> prop:</label>'
|
||||
+ '<select id="lang-select">'
|
||||
+ LANGUAGES.map((l) => '<option value="' + l.code + '"' + (l.code === currentLocale ? ' selected' : '') + '>' + l.name + '</option>').join('')
|
||||
+ '</select>'
|
||||
bar.querySelector('#lang-select').addEventListener('change', (e) => {
|
||||
currentLocale = e.target.value; localStorage.setItem(LOCALE_KEY, currentLocale); renderShowcase()
|
||||
})
|
||||
}
|
||||
function renderTabs(bundles) {
|
||||
const tabsEl = document.getElementById('bundleTabs'); if (!tabsEl) return
|
||||
tabsEl.innerHTML = bundles.map((b, i) => {
|
||||
const tags = (b.tags.length ? b.tags : ['??']).map((t) => '<span class="etag"><' + t + '></span>').join('')
|
||||
const n = slotsFor(b).length || 1
|
||||
return '<button class="btab' + (b.name === activeBundle ? ' active' : '') + '" data-name="' + b.name + '" style="--gem:' + GEMS[i % GEMS.length] + '">'
|
||||
+ '<span class="bname"><span class="dot"></span>' + b.name + '</span>'
|
||||
+ '<span class="btags">' + tags + '</span>'
|
||||
+ '<span class="bfile">' + b.name + '.js · ' + n + ' map' + (n === 1 ? '' : 's') + '</span></button>'
|
||||
}).join('')
|
||||
tabsEl.querySelectorAll('.btab').forEach((btn) => btn.addEventListener('click', () => select(btn.getAttribute('data-name'))))
|
||||
}
|
||||
function renderShowcase() {
|
||||
teardown()
|
||||
const bundles = discover()
|
||||
const i = bundles.findIndex((x) => x.name === activeBundle)
|
||||
const b = bundles[i]
|
||||
const sc = document.getElementById('showcase')
|
||||
if (!b) { sc.innerHTML = '<p class="empty">Pick a JS bundle above to see its maps.</p>'; return }
|
||||
const gem = GEMS[i % GEMS.length]
|
||||
slotsFor(b).forEach((sl) => sc.appendChild(buildTile(b, sl, gem)))
|
||||
}
|
||||
function select(name) {
|
||||
activeBundle = name; localStorage.setItem(ACTIVE_KEY, name)
|
||||
document.querySelectorAll('#bundleTabs .btab').forEach((btn) => btn.classList.toggle('active', btn.getAttribute('data-name') === name))
|
||||
window.scrollTo({ top: 0 })
|
||||
renderShowcase()
|
||||
}
|
||||
function render() {
|
||||
const bundles = discover()
|
||||
if (!bundles.some((b) => b.name === activeBundle)) activeBundle = bundles[0]?.name || ''
|
||||
const count = document.getElementById('count')
|
||||
if (count) {
|
||||
const totalMaps = bundles.reduce((n, b) => n + Math.max(slotsFor(b).length, 1), 0)
|
||||
count.textContent = bundles.length + ' JS bundle(s), ' + totalMaps + ' map(s) — scroll the strip, click a bundle'
|
||||
}
|
||||
renderLangBar(); renderTabs(bundles); renderShowcase()
|
||||
}
|
||||
|
||||
render()
|
||||
if (import.meta.hot) import.meta.hot.accept(() => render())
|
||||
48584
data/tables/doxa-gospel-access.json
Normal file
48584
data/tables/doxa-gospel-access.json
Normal file
File diff suppressed because it is too large
Load diff
46370
data/tables/doxa-people-groups.json
Normal file
46370
data/tables/doxa-people-groups.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,315 +0,0 @@
|
|||
# doxa-maps — prayer maps that embed everywhere
|
||||
|
||||
> **Bottom line up front.** A global prayer movement stalls when there is only **one** website where people
|
||||
> can see the task and sign up — today that plateau sits at roughly 600–1,000 people groups covered. The fix is
|
||||
> not a better single website. It is a map that lives on **thousands** of partner-team websites at once, shows
|
||||
> the prayer task clearly, changes as people sign up, and carries a **Share** button so every team that receives
|
||||
> it can pass it on. This bundler builds that map — and every other map you need — as one embeddable file each.
|
||||
|
||||
## Why embed, not link
|
||||
|
||||
One sign-up site is a bottleneck. A map embedded on a partner team's *own* site multiplies three surfaces at
|
||||
once: the **prayer surface** (more people see the task), the **information surface** and the **understanding
|
||||
surface**. And a team that has the map on its own site treats it as its own — ownership becomes stewardship.
|
||||
Sign-up can still happen in one place; the *map* does not have to.
|
||||
|
||||
## What this trains you to be (Training for Trainers)
|
||||
|
||||
We borrow the language of **Training for Trainers (T4T)** — Ying Kai's method, told in *T4T: A Discipleship
|
||||
Re-Revolution* (Steve Smith with Ying Kai, 2011; trainer resources at [t4tonline.org](https://t4tonline.org)):
|
||||
the goal is never one trained person, but trainers who train trainers. This README trains you to be three things:
|
||||
|
||||
1. a **map maker** — you (with your AI agent) build maps on the shared library in this repo;
|
||||
2. a **map multiplier** — every map you build embeds anywhere and carries a Share button;
|
||||
3. a **prayer mobilizer** — you run the campaign that multiplies the surface: the map onto the website of
|
||||
every team you know that is trying to multiply disciples among the unreached, and each of them trained to
|
||||
pass it on with the same button — **[the Playbook](#the-playbook--start-a-prayer-movement-with-this-map)**.
|
||||
|
||||
Everything below is for the people who build the maps. If you only want to *run the campaign*, jump to the
|
||||
Playbook; the full walkthrough is [documentation/tutorial.md](./tutorial.md).
|
||||
|
||||
---
|
||||
|
||||
## What the bundler is — a JAMstack map bundler for vibe coders
|
||||
|
||||
> **Built by a vibe coder, for vibe coders** — and for map multipliers and prayer mobilizers who
|
||||
> want every people, in every place, prayed for by name.
|
||||
|
||||
**One build turns every map into a self‑contained web component** that embeds anywhere an embed
|
||||
script or URL works, and every map carries the **Share button** so any team can pass its map to the
|
||||
next team. **The seemingly impossible part:** the same build also composes all your maps, tables, and
|
||||
dashboards into a single‑page application that runs as a website, installs as a mobile app, and
|
||||
embeds on partner sites piece by piece. *Same code, every screen.*
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/spa-desktop.png" alt="The composed SPA on desktop — many maps, tables and dashboards in one app" width="680">
|
||||
<img src="assets/spa-mobile.png" alt="The same SPA, same code, as a phone app" width="170">
|
||||
</p>
|
||||
<p align="center"><em>One build, same code: the SPA in a desktop browser and on a phone — many maps, one place.</em></p>
|
||||
|
||||
- **One repo, one library** — every map shares components. Not fifty folders of duplicated spaghetti.
|
||||
- **`bun run dev`** → a live plugin gallery of every map, each with a copy‑paste embed snippet.
|
||||
- **`bun run build`** → every map ships as its own self‑contained embeddable file (script tag or iframe — WordPress included), and the same build feeds the SPA and the mobile app.
|
||||
|
||||
**You don't write the code.** Point your AI agent at the example maps, describe what you want in plain English, and it builds a new one on the shared library.
|
||||
|
||||
> ⚠️ **Maps ship without data** — being JAMstack (pure JS), they fetch it (like people groups) from an **API** at runtime ([details](#the-data-caveat)).
|
||||
|
||||
---
|
||||
|
||||
## Have you ever dreamed of making maps?
|
||||
|
||||
Here's the story. Maybe it's yours too.
|
||||
|
||||
You got into building software right as AI made it possible for anyone to write code. And you had a
|
||||
vision: **make maps** — every map you could imagine — to advance the Kingdom of God, so that every people
|
||||
in every place could hear the gospel of Jesus.
|
||||
|
||||
So you started. And it worked… for one map. AI is *great* at writing a single map — a couple thousand
|
||||
lines of code you can embed on a website.
|
||||
|
||||
But then you wanted a second map. And a fifth. And a fiftieth. And here's the wall you hit:
|
||||
|
||||
- Every new map meant **copying the whole thing** into a new folder — nothing was reusable.
|
||||
- Your maps grew past 2,000… 5,000… lines of tangled "spaghetti code."
|
||||
- You ended up with **50 folders, 50 map projects**, scattered across your machine — half of them lost.
|
||||
- Each one was fragile, glitchy, and difficult to maintain, and harder to form a community around.
|
||||
- You tried to build the bigger platform — many maps in one app — and the agent got stuck past map one. Somewhere in there, the mobile‑app dream quietly went out the window.
|
||||
|
||||
**If that's you: you built maps for Jesus. I salute you.** Those maps have their place. But you were
|
||||
missing one thing — a *framework* that lets all your maps live in one place and share reusable pieces.
|
||||
|
||||
That's what this is. Built for communities of map makers who want to make open source maps that can multiply everywhere. And the dream you threw out the window? The SPA here runs on your phone **by design** — same code.
|
||||
|
||||
---
|
||||
|
||||
## Key terms (don't be afraid of these — you can learn each in one search)
|
||||
|
||||
New to this? Read this table first. You do **not** need to memorize it — just know the words exist and where to learn them.
|
||||
|
||||
| Term | In plain words | Learn more |
|
||||
|---|---|---|
|
||||
| **Vibe coder** | Someone who builds with AI writing most of the code. That's who this is for. | — |
|
||||
| **JAMstack** | A way of building where the front end is just **J**avaScript, **A**PIs, and **M**arkup (HTML/CSS). It runs almost anywhere — including WordPress. | [jamstack.org](https://jamstack.org) |
|
||||
| **Bundler** | A tool that takes all your code and packs it into a few ready‑to‑ship files. This repo *is* a bundler for maps. | [vite.dev/guide](https://vite.dev/guide/) |
|
||||
| **Build step** | The one command (`bun run build`) that turns your code into those shippable files. Your agent can run it for you. | — |
|
||||
| **bun** | A fast, all‑in‑one replacement for `npm` (installs packages, runs scripts). We use it here. | [bun.sh](https://bun.sh) |
|
||||
| **node modules** | The reusable code packages your project depends on. If you use VUE, and VITE you need Node Modules. It is like installing libraries and dependencies that allow VUE to work. `bun install` downloads them into your doxa-maps folder on your laptop `node_modules/`. | [bun.sh/docs](https://bun.sh/docs/cli/install) |
|
||||
| **Dev server** | A local preview website (run this command 'bun run dev' after 'bun install') that updates live as you edit. You run it, it let's you see all the maps in one place so you can give your agents feedback, and see changes almost real time when the agent makes edits. It runs at `http://localhost:5173`. | — |
|
||||
| **Vue / React** | Popular, well‑supported frameworks for building front ends. This bundler is built on **Vue** + **Vite** — commit to learning Vue (if you know React, the concepts transfer). | [vuejs.org](https://vuejs.org) |
|
||||
| **Web component** | A custom HTML tag (like `<doxa-map>`) that works on any page, no framework required. Each of your maps becomes one. | [MDN: Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) |
|
||||
| **iframe** | A safe window that embeds one site inside another. Simple, isolated. Each map comes with iframe support by default | [MDN: iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) |
|
||||
| **Shadow DOM** | Lets your map's JavaScript run *directly* inside the host page (not walled off in an iframe) so it can integrate deeply — while keeping its styles from clashing. | [MDN: Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) |
|
||||
| **SPA (single‑page application)** | One page that composes several maps together in a layout — a whole multi-map site from one build. | [MDN: SPA](https://developer.mozilla.org/en-US/docs/Glossary/SPA) |
|
||||
| **CDN** | A URL that serves a file (JavaScript/CSS) to be fetched and run in a browser. When you use Mapbox, you're loading its CDN. Your built maps are served the same way. JAMstack allows these map frontends to run through a CDN, yes they are that modular! Or, you can run them directly in your site, but CDN allows them to embed via iframe onto partner websites easily with a single URL | [MDN: CDN](https://developer.mozilla.org/en-US/docs/Glossary/CDN) |
|
||||
| **Minify** | The build shrinks your code into compact, hard‑to‑read (but still working) form — smaller and harder to copy. | [MDN: Minification](https://developer.mozilla.org/en-US/docs/Glossary/Minification) |
|
||||
| **Capacitor** | Wraps your maps app into a real iOS/Android apps, or make a SPA ('single-page application' see above) by joining multiple maps together into a layout, and bundling that SPA into IOS/Android easily — **same code, no Swift, no Flutter, no React Native.** (The Android project ships in this repo; iOS scaffolds the same way on a Mac.) | [capacitorjs.com](https://capacitorjs.com) |
|
||||
| **Lazy loading** | Only loads each map when it's actually needed, so a page with many maps stays fast. | [web.dev: lazy loading](https://web.dev/articles/lazy-loading) |
|
||||
|
||||
---
|
||||
|
||||
## What doxa-maps gives you
|
||||
|
||||
- **One repository for all your maps.** Each map is an *application profile* in `plugin-designs/<your-map>/`.
|
||||
- **A shared library of reusable, parameterized components** (`library/` — the map, the legend, the
|
||||
controls, the search bar) so your maps stop duplicating code.
|
||||
- **Room for each map's own custom code** — because real maps always have something unique.
|
||||
- **Rapid prototyping.** Add a `.vue` app-profile file, rebuild, and it's live. Build *hundreds* of maps from one library.
|
||||
- **Your agent's happy place.** Everything's in one folder, so your AI agent has all the context it needs to
|
||||
build the next map from the examples already here. Give your agent the proper context and it will have what it needs to make new maps. The only limit is our fear of new architecture, we can learn JAMstack, and see the use cases, and grow in a new framework of software design that is appropriate for open source maps that need to embed everywhere.
|
||||
- **The Great Commission's Maps** Everyone needs to work together to fulfill the great commission, and it will not be possible without maps. Allowing map communities to multiply open source maps that can embed onto all partner sites means doxa-maps is built to solve global mobilization problems, not just a map for centralized website.
|
||||
|
||||
---
|
||||
|
||||
## The parts that make you do a backflip
|
||||
|
||||
- **One build → many embeddable maps.** `bun run build` turns every map into a self‑contained web component
|
||||
*and* generates a standalone HTML page for each one.
|
||||
- **Embed anywhere.** Drop a map on your own site, a partner team's site, or WordPress — via an **iframe**
|
||||
(safe and isolated) *or* via **shadow DOM** (the map's JavaScript runs directly in the page, for deep
|
||||
integration and advanced features).
|
||||
- **Compose maps into a single‑page app.** Put several maps into one layout — a whole multi‑map site — and it
|
||||
**lazy‑loads** each map so it stays fast even when there are many.
|
||||
- **Turn it into a mobile app.** The same code runs as an **iOS/Android app** via **Capacitor** — no new
|
||||
language to learn. Design it mobile‑friendly right in your browser (inspect element → switch to a phone
|
||||
aspect ratio), then wrap it.
|
||||
- **Your source stays protected.** The build **minifies** your code — it still runs perfectly, but anyone who
|
||||
grabs it off a partner site gets compact, hard‑to‑read output, not your clean source.
|
||||
- **Update once, everywhere.** Serve the built maps from a **CDN**, and when you rebuild, every partner site
|
||||
that embeds a map picks up the latest version when their cache refreshes.
|
||||
|
||||
**One codebase → your own map website · embedded on partner sites · WordPress · mobile app · standalone.**
|
||||
|
||||
---
|
||||
|
||||
## The Playbook — start a prayer movement with this map
|
||||
|
||||
The learn‑by‑doing use case this bundler was built for. Five steps, trainable to any team:
|
||||
|
||||
1. **List your partner teams.** Build a spreadsheet of every team you know that is working to
|
||||
multiply disciples among the unreached — their websites, and the contact who can edit each site.
|
||||
2. **Keep the list in a secure, collaborative CRM — not a loose file.** That list of teams,
|
||||
contacts, and sites is an **attack surface** in the wrong hands; protect the people on it.
|
||||
[Disciple.Tools](https://disciple.tools) is an open‑source, multiplayer CRM built exactly for
|
||||
tracking teams, contacts, and mobilization campaigns — privately.
|
||||
3. **Commission prayer mobilizers.** Assign real people to work the list — call, network, and ask
|
||||
each team to put the prayer map on their site and present it to their spiritual family.
|
||||
4. **Embed everywhere.** Press the map's **Share button**: *Share Link* is the map's own page,
|
||||
*Add to Your Website* is the embed code — both carry the full URL of whatever host serves your
|
||||
maps (plus a `utm_source` tag on the generated pages, so every site's traffic is measurable).
|
||||
**WordPress is verified here**: `bun run check:wp-embed` renders the real shortcode against the
|
||||
real built manifest and asserts it emits the same SRI hash the catalog ships, with a negative
|
||||
case for a malformed integrity value. It runs in the build, needs no MySQL or Docker, and is the
|
||||
one embed target this repo can prove on its own.
|
||||
**Expected to work, not tested by us** — each accepts an HTML snippet or a URL embed over https,
|
||||
which is all the embed code needs: Squarespace (Code block — iframes need a Business or Commerce
|
||||
plan), Wix (Add → Embed → HTML iframe), Google Sites (Insert → Embed → By URL), Notion (`/embed`
|
||||
plus the Share Link), and internal boards with an iframe add-on (e.g. Trello's *Card Frames*
|
||||
Power-Up). Rule of thumb: anywhere that accepts an HTML snippet or a URL embed, over https.
|
||||
*(This paragraph said "Verified places a pasted embed works" and listed all seven. Corrected
|
||||
2026-09-23: the repo holds an artifact for exactly one of them. The platforms are plausible and
|
||||
the mechanism is the same everywhere; the word "verified" was the part that overreached, and in a
|
||||
hand-off doc it reads as "someone tried this" when nobody did.)*
|
||||
5. **Train the trainer.** Teach each team to use the Share button to embed the map — then teach
|
||||
them to train the *next* team the same way. The movement doesn't hang on one mobilization
|
||||
team: every team that carries the map can multiply it.
|
||||
|
||||
The result: one signup flow, a thousand doorways into it — and a map that visibly changes as
|
||||
people commit, so every team watches the task shrink on *their own* site.
|
||||
|
||||
## A word before you decide
|
||||
|
||||
This architecture is probably different from what you're used to. **Different doesn't mean worse** — it
|
||||
means it can make your life better and let you build the maps of your dreams.
|
||||
|
||||
So be honest with yourself. If you don't have big dreams about map‑making, this product might not be for
|
||||
you — and that's okay. But if you dream of maps that change the world and carry the gospel to places it has
|
||||
never been, you're going to need **more than a vibe‑coded map**. You'll need a *framework* that supports far
|
||||
more components than a typical one‑off map. And you'll need a *community*.
|
||||
|
||||
You can't form a community around spaghetti‑coded maps. You **can** form one around a shared framework with a
|
||||
reusable library — because then people prototype on the same foundation and build something stronger
|
||||
together, where maps never have to be rewritten from scratch. One problem that hinders the Great Commission, is each worker keeps their maps private source, and don't have a shared map framework to build maps on, with reusable map components in a modern framework like VUE with VITE embeddability support. **Doxa-maps** aims to meet this open source map gap for a global prayer movement which needs more maps, maps, maps!
|
||||
|
||||
## Requirements — the one small wall (and it's worth it)
|
||||
|
||||
Someone once told me: *don't reinvent the wheel — use Vue and Vite instead of hand‑rolling vanilla
|
||||
JavaScript.* They were right. So here's the honest deal before you start:
|
||||
|
||||
1. **Commit to learning Vue.** This bundler is built on **Vue + Vite** (React skills transfer —
|
||||
the components you'll touch are Vue).
|
||||
You don't have to master it — but pick one and lean in. (Afraid you can't learn a new architecture? You
|
||||
can. The concepts are one search away — that's what the glossary above is for.)
|
||||
2. **Learn the four basics** (your agent can run all of them):
|
||||
- `bun install` — get the dependencies
|
||||
- `bun run dev` — the dev server (live preview)
|
||||
- `bun run build` — the build step (ship it)
|
||||
- what `node_modules` are (the downloaded dependencies)
|
||||
3. **You do *not* need to write the code.** Give your agent access to the example maps in `plugin-designs/` plus
|
||||
the shared `library/`, describe your map in plain English, and it builds a new map that reuses the library
|
||||
instead of duplicating spaghetti. The library is for reusable components while the app profiles is for storing custom components that are not reusable, and are specific to that app-profile or map-app. Each map app has custom components that are not reusable and they are not appropriate in the shared library, but if you look at other plugin-designs you will see they store their custom components, as well as their main.vue file for the map in the plugin-designs folder.
|
||||
|
||||
That's the wall. It's small, and everything past it is the maps of your dreams.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
You need [**bun**](https://bun.sh) installed (`bun.lock` is committed, so installs are reproducible).
|
||||
|
||||
```bash
|
||||
bun install # download node_modules (one time)
|
||||
|
||||
bun run dev # the plugin STUDIO: http://localhost:5173 (or the next free port — read the terminal)
|
||||
# `/` mounts the studio; the ARCHIVED bundle index is at /__gallery — an
|
||||
# auto-generated SPA of every map, grouped by bundle and composition tier, each
|
||||
# deep-linkable. `bun run smoke:studio` checks the studio (~5s); `bun run
|
||||
# smoke:gallery` proves every element boots on the archived /__gallery page
|
||||
|
||||
BUILD_ALLOW=1 bun run build # the build step: every plugin-designs/<map>/ becomes
|
||||
# public/doxa-maps/<map>/<map>.js + a standalone index.html per map
|
||||
|
||||
bun run serve # preview the BUILT tree (the SPA shell + every embed page)
|
||||
bun run phone # build → sync → Android APK on a connected phone (Capacitor)
|
||||
```
|
||||
|
||||
`bun run build` writes everything into **`public/doxa-maps/`** — that's your output folder. Copy it wherever
|
||||
your site or CDN serves files (for example, a host repo's `public/` directory), and your maps are live.
|
||||
|
||||
### Embed a map
|
||||
|
||||
```html
|
||||
<!-- 1) the map engine (Mapbox shown here) -->
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
|
||||
|
||||
<!-- 2) your built map + its custom tag -->
|
||||
<script src="/doxa-maps/my-map/my-map.js"></script>
|
||||
<doxa-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></doxa-map>
|
||||
```
|
||||
|
||||
Prefer not to run JavaScript on the host page? **iframe the generated page instead:**
|
||||
`/doxa-maps/<map>/<profile>/index.html`. Both snippets are pre‑generated for every map in
|
||||
`public/doxa-maps/manifest.json`.
|
||||
|
||||
---
|
||||
|
||||
## The data caveat
|
||||
|
||||
Read this so nothing surprises you: **the maps ship without their data.** Because they're JAMstack (pure
|
||||
JavaScript), each map fetches its data — like people‑group information — from an **API** at runtime. That's
|
||||
what keeps them embeddable everywhere.
|
||||
|
||||
Your map data has to come from somewhere. Common sources:
|
||||
|
||||
- **[Joshua Project](https://joshuaproject.net)**
|
||||
- **[PeopleGroups.org](https://peoplegroups.org)**
|
||||
- **A project API.** doxa-maps was originally built to serve **DX**, which runs its own people‑group API
|
||||
(`https://pray.doxa.life`) covering the unreached people groups these maps were made to pray over. The data
|
||||
source, the map token, and even the map engine (Mapbox ↔ MapLibre) are **runtime settings** — change them
|
||||
with **no rebuild**.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
| Read this | When |
|
||||
|---|---|
|
||||
| **[documentation/why.md](./why.md)** | 2 min — the problem, the idea, one diagram |
|
||||
| **[documentation/tutorial.md](./tutorial.md)** | build your first map, step by step (great to hand to your agent) |
|
||||
| **[documentation/how-to.md](./how-to.md)** | task recipes (embed, data, mobile) |
|
||||
| **[library/layouts/README.md](../library/layouts/README.md)** | the SPA composition machinery (many maps → one app) |
|
||||
| **[documentation/architecture/](./architecture/)** | the deep end — embeddability, app-wrappers, file map |
|
||||
|
||||
---
|
||||
|
||||
## A note from the developer
|
||||
|
||||
> **Bottom line: maps should be open source, they should run everywhere, and no movement of God has ever
|
||||
> moved without maps.**
|
||||
|
||||
You found some really cool maps on the internet, didn't you? On discipleship sites, maybe — maps that
|
||||
inspired you, that made you want to build your own. But the code wasn't available. Or if it was, it wasn't
|
||||
written to run on WordPress, or on your own site, or across your partner teams' websites. It wasn't
|
||||
simple. It wasn't modular. It didn't work the way you needed it to.
|
||||
|
||||
That was me too. That's why I built this framework. I love maps — I'm going to build maps until the day I
|
||||
die — because I want to see the Kingdom of God advance to the places it has never been, and I long to see a
|
||||
global movement of map‑makers multiplying maps everywhere.
|
||||
|
||||
I want the Great Commission fulfilled in my lifetime, and there is no global movement without map‑making.
|
||||
Look at Scripture: *Jerusalem, Judea, Samaria, and the ends of the earth* — the book of Acts is a map, all
|
||||
the way to Rome. Where do you find a movement of God without maps at its white‑hot center? Nowhere. So why
|
||||
would we lock our maps away and make them closed source? Why should the power to make a map sit in the hands
|
||||
of a few who never release their code?
|
||||
|
||||
It shouldn't. **This was built to be open source, to serve movements of God** — so the maps can multiply to
|
||||
every device: WordPress, your own site, an embeddable script on Squarespace, mobile, anywhere an embed can
|
||||
run. If we want to put map‑making into the hands of every disciple, we need a framework that lets the maps
|
||||
*go*, lets them be *reusable*, and lets a community build them *together*.
|
||||
|
||||
If you have a vision to reach the globe but you've been stuck vibe‑coding an entire platform alone — with no
|
||||
open‑source foundation that runs on your site and your partners' sites — that gap isn't your fault, and you
|
||||
don't have to stay there. You can escape it by adopting a framework that has already been where you are.
|
||||
|
||||
Come build the maps of your dreams. Let's reach every people, in every place.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 667 KiB |
|
|
@ -1,65 +1,159 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1300" height="1300" viewBox="0 0 1300 1300" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1826" viewBox="0 0 1500 1826" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<defs>
|
||||
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
|
||||
<marker id="arS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
|
||||
<path id="stLabel" d="M 526,720 A 124,124 0 0 1 774,720"/>
|
||||
<path id="plLabel" d="M 342,720 A 308,308 0 0 1 958,720"/>
|
||||
<path id="agLabel" d="M 184,720 A 466,466 0 0 1 1116,720"/>
|
||||
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
|
||||
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
|
||||
<marker id="aM" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#b91c1c"/></marker>
|
||||
<marker id="aK" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#334155"/></marker>
|
||||
<path id="stL" d="M 588,850 A 162,162 0 0 1 912,850"/>
|
||||
<path id="plL" d="M 379,850 A 371,371 0 0 1 1121,850"/>
|
||||
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
|
||||
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
|
||||
</defs>
|
||||
<rect width="1300" height="1300" fill="#ffffff"/>
|
||||
<circle cx="650" cy="720" r="450" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="3 7" opacity=".7"/>
|
||||
<circle cx="650" cy="720" r="290" fill="none" stroke="#15803d" stroke-width="2" opacity=".8"/>
|
||||
<circle cx="650" cy="720" r="150" fill="#fffbeb" stroke="#d97706" stroke-width="3"/>
|
||||
<text font-size="22" font-weight="800" fill="#d97706" letter-spacing="3"><textPath href="#stLabel" startOffset="50%" text-anchor="middle">PLUGIN STUDIO</textPath></text>
|
||||
<text x="650" y="728" text-anchor="middle" font-size="16" fill="#92400e">one repo · one shared library</text>
|
||||
<text x="650" y="752" text-anchor="middle" font-size="16" fill="#92400e">every plugin in one place</text>
|
||||
<text font-size="15" font-weight="700" fill="#15803d" letter-spacing="2"><textPath href="#plLabel" startOffset="50%" text-anchor="middle">PLUGINS</textPath></text>
|
||||
<text font-size="15" font-weight="700" fill="#2563eb" letter-spacing="2"><textPath href="#agLabel" startOffset="50%" text-anchor="middle">AGENTS</textPath></text>
|
||||
<line x1="827.9" y1="350.6" x2="800.1" y2="408.3" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="775.8" cy="458.7" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="775.8" y="455.7" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Big</text>
|
||||
<text x="775.8" y="472.7" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Picture</text>
|
||||
<circle cx="845.2" cy="314.6" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="845.2" y="319.6" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="1049.7" y1="628.8" x2="987.3" y2="643.0" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="932.7" cy="655.5" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="932.7" y="652.5" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Simple</text>
|
||||
<text x="932.7" y="669.5" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Research</text>
|
||||
<circle cx="1088.7" cy="619.9" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="1088.7" y="624.9" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="970.6" y1="975.6" x2="920.5" y2="935.7" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="876.7" cy="900.8" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="876.7" y="905.8" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Regions</text>
|
||||
<circle cx="1001.8" cy="1000.6" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="1001.8" y="1005.6" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="650.0" y1="1130.0" x2="650.0" y2="1066.0" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="650.0" cy="1010.0" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="650.0" y="1007.0" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">UPG-100</text>
|
||||
<text x="650.0" y="1024.0" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">List</text>
|
||||
<circle cx="650.0" cy="1170.0" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="650.0" y="1175.0" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="329.4" y1="975.6" x2="379.5" y2="935.7" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="423.3" cy="900.8" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="423.3" y="897.8" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Prayer</text>
|
||||
<text x="423.3" y="914.8" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Progress</text>
|
||||
<circle cx="298.2" cy="1000.6" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="298.2" y="1005.6" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="250.3" y1="628.8" x2="312.7" y2="643.0" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="367.3" cy="655.5" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="367.3" y="652.5" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Cities with</text>
|
||||
<text x="367.3" y="669.5" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Internet</text>
|
||||
<circle cx="211.3" cy="619.9" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="211.3" y="624.9" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="472.1" y1="350.6" x2="499.9" y2="408.3" stroke="#2563eb" stroke-width="2.2" marker-end="url(#ar)"/>
|
||||
<circle cx="524.2" cy="458.7" r="50" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="524.2" y="455.7" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Gospel</text>
|
||||
<text x="524.2" y="472.7" text-anchor="middle" font-size="13" font-weight="700" fill="#14532d">Resources</text>
|
||||
<circle cx="454.8" cy="314.6" r="38" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="454.8" y="319.6" text-anchor="middle" font-size="13" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<circle cx="650" cy="120" r="62" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3"/>
|
||||
<text x="650" y="126" text-anchor="middle" font-size="17" font-weight="800" fill="#4c1d95">shepherd</text>
|
||||
<line x1="650" y1="184" x2="650" y2="228" stroke="#7c3aed" stroke-width="2.2" stroke-dasharray="6 5" marker-end="url(#arS)"/>
|
||||
<text x="734" y="112" font-size="17" fill="#4c1d95">one shepherd makes sure all plugin agents</text>
|
||||
<text x="734" y="136" font-size="17" fill="#4c1d95">are doing their job building the plugins well</text>
|
||||
<text x="650" y="1250" text-anchor="middle" font-size="19" font-weight="700" fill="#1e3a8a">each plugin has one agent responsible for building it</text>
|
||||
<rect width="1500" height="1826" fill="#ffffff"/>
|
||||
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one studio, one agent per plugin, one shepherd</text>
|
||||
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
|
||||
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
|
||||
<circle cx="750" cy="850" r="606" fill="none" stroke="#7c3aed" stroke-width="2" stroke-dasharray="8 7" opacity=".6"/>
|
||||
<line x1="980.4" y1="293.8" x2="973.5" y2="310.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="1306.2" y1="619.6" x2="1289.5" y2="626.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="1306.2" y1="1080.4" x2="1289.5" y2="1073.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="980.4" y1="1406.2" x2="973.5" y2="1389.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="519.6" y1="1406.2" x2="526.5" y2="1389.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="193.8" y1="1080.4" x2="210.5" y2="1073.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="193.8" y1="619.6" x2="210.5" y2="626.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<line x1="519.6" y1="293.8" x2="526.5" y2="310.5" stroke="#7c3aed" stroke-width="2" opacity=".7" marker-end="url(#aS)"/>
|
||||
<circle cx="750" cy="850" r="190" fill="#fffbeb" stroke="#d97706" stroke-width="3.5"/>
|
||||
<text font-size="24" font-weight="800" fill="#d97706" letter-spacing="4"><textPath href="#stL" startOffset="50%" text-anchor="middle">PLUGIN STUDIO</textPath></text>
|
||||
<text x="750" y="770" text-anchor="middle" font-size="14" fill="#92400e">one repo · every plugin in one place</text>
|
||||
<text x="750" y="790" text-anchor="middle" font-size="14" fill="#92400e">shared library/ (@map)</text>
|
||||
<circle cx="750" cy="884" r="78" fill="#fef2f2" stroke="#dc2626" stroke-width="2" stroke-dasharray="7 5"/>
|
||||
<circle cx="750" cy="884" r="44" fill="#166534"/>
|
||||
<text x="750" y="890" text-anchor="middle" font-size="22" font-weight="800" fill="#fff">main</text>
|
||||
<text x="750" y="906" text-anchor="middle" font-size="10.5" fill="#dcfce7">always green</text>
|
||||
<text font-size="11" font-weight="700" fill="#b91c1c" letter-spacing="1.2"><textPath href="#mqL" startOffset="50%" text-anchor="middle">MERGE QUEUE · ONE AT A TIME</textPath></text>
|
||||
<text font-size="16" font-weight="800" fill="#15803d" letter-spacing="3"><textPath href="#plL" startOffset="50%" text-anchor="middle">PLUGINS</textPath></text>
|
||||
<text font-size="16" font-weight="800" fill="#2563eb" letter-spacing="3"><textPath href="#agL" startOffset="18%" text-anchor="middle">AGENTS</textPath></text>
|
||||
<line x1="862.1" y1="579.3" x2="822.7" y2="674.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="940.6" y1="389.9" x2="912.3" y2="458.3" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="885.9" cy="522.0" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="885.9" y="520.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Big Picture</text>
|
||||
<rect x="859.9" y="537.0" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="885.9" y="552.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="956.6" cy="351.1" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="956.6" y="356.1" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="1020.7" y1="737.9" x2="925.5" y2="777.3" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="1210.1" y1="659.4" x2="1141.7" y2="687.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="1078.0" cy="714.1" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="1078.0" y="704.1" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Simple</text>
|
||||
<text x="1078.0" y="721.1" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Research</text>
|
||||
<rect x="1052.0" y="729.1" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="1078.0" y="744.1" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="1248.9" cy="643.4" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="1248.9" y="648.4" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="1020.7" y1="962.1" x2="925.5" y2="922.7" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="1210.1" y1="1040.6" x2="1141.7" y2="1012.3" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="1078.0" cy="985.9" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="1078.0" y="983.9" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Regions</text>
|
||||
<rect x="1052.0" y="1000.9" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="1078.0" y="1015.9" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="1248.9" cy="1056.6" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="1248.9" y="1061.6" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="862.1" y1="1120.7" x2="822.7" y2="1025.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="940.6" y1="1310.1" x2="912.3" y2="1241.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="885.9" cy="1178.0" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="885.9" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">UPG-100</text>
|
||||
<text x="885.9" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">List</text>
|
||||
<rect x="859.9" y="1193.0" width="52" height="20" rx="10" fill="#15803d"/>
|
||||
<text x="885.9" y="1208.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 4</text>
|
||||
<circle cx="956.6" cy="1348.9" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="956.6" y="1353.9" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="637.9" y1="1120.7" x2="677.3" y2="1025.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="559.4" y1="1310.1" x2="587.7" y2="1241.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="614.1" cy="1178.0" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="614.1" y="1168.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Prayer</text>
|
||||
<text x="614.1" y="1185.0" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Progress</text>
|
||||
<rect x="588.1" y="1193.0" width="52" height="20" rx="10" fill="#0d9488"/>
|
||||
<text x="614.1" y="1208.0" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 2</text>
|
||||
<circle cx="543.4" cy="1348.9" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="543.4" y="1353.9" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="479.3" y1="962.1" x2="574.5" y2="922.7" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="289.9" y1="1040.6" x2="358.3" y2="1012.3" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="422.0" cy="985.9" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="422.0" y="975.9" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Cities with</text>
|
||||
<text x="422.0" y="992.9" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Internet</text>
|
||||
<rect x="396.0" y="1000.9" width="52" height="20" rx="10" fill="#65a30d"/>
|
||||
<text x="422.0" y="1015.9" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 1</text>
|
||||
<circle cx="251.1" cy="1056.6" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="251.1" y="1061.6" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="479.3" y1="737.9" x2="574.5" y2="777.3" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="289.9" y1="659.4" x2="358.3" y2="687.7" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="422.0" cy="714.1" r="62" fill="#f0fdf4" stroke="#15803d" stroke-width="2.6"/>
|
||||
<text x="422.0" y="704.1" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Gospel</text>
|
||||
<text x="422.0" y="721.1" text-anchor="middle" font-size="14" font-weight="800" fill="#14532d">Resources</text>
|
||||
<rect x="396.0" y="729.1" width="52" height="20" rx="10" fill="#65a30d"/>
|
||||
<text x="422.0" y="744.1" text-anchor="middle" font-size="11.5" font-weight="800" fill="#fff">cap 1</text>
|
||||
<circle cx="251.1" cy="643.4" r="40" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="251.1" y="648.4" text-anchor="middle" font-size="13.5" font-weight="700" fill="#1e3a8a">agent</text>
|
||||
<line x1="637.9" y1="579.3" x2="677.3" y2="674.5" stroke="#15803d" stroke-width="1.4" opacity=".45"/>
|
||||
<line x1="559.4" y1="389.9" x2="587.7" y2="458.3" stroke="#2563eb" stroke-width="2.4" marker-end="url(#aA)"/>
|
||||
<circle cx="614.1" cy="522.0" r="62" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5" stroke-dasharray="6 4"/>
|
||||
<circle cx="592.1" cy="504.0" r="8" fill="#0891b2" opacity=".75"/>
|
||||
<circle cx="614.1" cy="494.0" r="8" fill="#0891b2" opacity=".75"/>
|
||||
<circle cx="636.1" cy="504.0" r="8" fill="#0891b2" opacity=".75"/>
|
||||
<circle cx="602.1" cy="526.0" r="8" fill="#0891b2" opacity=".75"/>
|
||||
<circle cx="626.1" cy="526.0" r="8" fill="#0891b2" opacity=".75"/>
|
||||
<text x="614.1" y="556.0" text-anchor="middle" font-size="12" font-weight="800" fill="#155e75">Experimental</text>
|
||||
<text x="614.1" y="570.0" text-anchor="middle" font-size="10.5" fill="#155e75">shelf · 5 plugins</text>
|
||||
<circle cx="543.4" cy="351.1" r="40" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
|
||||
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">roaming</text>
|
||||
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">agent</text>
|
||||
<circle cx="750" cy="244" r="60" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3.5"/>
|
||||
<text x="750" y="251" text-anchor="middle" font-size="19" font-weight="800" fill="#4c1d95">shepherd</text>
|
||||
<text x="834" y="192" font-size="17" fill="#4c1d95">one shepherd makes sure all plugin agents</text>
|
||||
<text x="834" y="216" font-size="17" fill="#4c1d95">are doing their job building the plugins well</text>
|
||||
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">each plugin has one agent responsible for building it</text>
|
||||
<rect x="36" y="110" width="300" height="208" rx="14" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<circle cx="64" cy="144" r="12" fill="#fffbeb" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="88" y="149" font-size="15" fill="#334155">Plugin Studio — the one repo</text>
|
||||
<circle cx="64" cy="180" r="12" fill="#f0fdf4" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="88" y="185" font-size="15" fill="#334155">plugin (badge = capability)</text>
|
||||
<circle cx="64" cy="216" r="12" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
|
||||
<text x="88" y="221" font-size="15" fill="#334155">Experimental shelf</text>
|
||||
<circle cx="64" cy="252" r="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="88" y="257" font-size="15" fill="#334155">agent — builds one plugin</text>
|
||||
<circle cx="64" cy="288" r="12" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
|
||||
<text x="88" y="293" font-size="15" fill="#334155">shepherd — oversees all</text>
|
||||
<line x1="750" y1="956" x2="750" y2="1542" stroke="#334155" stroke-width="2.5" stroke-dasharray="2 5" opacity=".0"/>
|
||||
<rect x="60" y="1536" width="1380" height="230" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<text x="750" y="1580" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">main → ONE build → public/doxa-maps/ → five ways to ship</text>
|
||||
<rect x="80.0" y="1608" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="198.0" y="1630" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 1</text>
|
||||
<text x="198.0" y="1662" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">iframe</text>
|
||||
<text x="198.0" y="1688" text-anchor="middle" font-size="15" fill="#475569">any website</text>
|
||||
<rect x="120.0" y="1702" width="156" height="26" rx="13" fill="#15803d" opacity=".13"/>
|
||||
<text x="198.0" y="1720" text-anchor="middle" font-size="13" font-weight="800" fill="#15803d">PRODUCTION</text>
|
||||
<rect x="356.0" y="1608" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="474.0" y="1630" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 2</text>
|
||||
<text x="474.0" y="1662" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">Mobile app</text>
|
||||
<text x="474.0" y="1688" text-anchor="middle" font-size="15" fill="#475569">Capacitor</text>
|
||||
<rect x="396.0" y="1702" width="156" height="26" rx="13" fill="#15803d" opacity=".13"/>
|
||||
<text x="474.0" y="1720" text-anchor="middle" font-size="13" font-weight="800" fill="#15803d">BUILT</text>
|
||||
<rect x="632.0" y="1608" width="236" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="750.0" y="1630" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 3</text>
|
||||
<text x="750.0" y="1662" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">JavaScript embed</text>
|
||||
<text x="750.0" y="1688" text-anchor="middle" font-size="15" fill="#475569">script + SRI</text>
|
||||
<rect x="672.0" y="1702" width="156" height="26" rx="13" fill="#15803d" opacity=".13"/>
|
||||
<text x="750.0" y="1720" text-anchor="middle" font-size="13" font-weight="800" fill="#15803d">PRODUCTION</text>
|
||||
<rect x="908.0" y="1608" width="236" height="130" rx="14" fill="#fff" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="1026.0" y="1630" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 4</text>
|
||||
<text x="1026.0" y="1662" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">WordPress</text>
|
||||
<text x="1026.0" y="1688" text-anchor="middle" font-size="15" fill="#475569">Disciple Tools</text>
|
||||
<rect x="948.0" y="1702" width="156" height="26" rx="13" fill="#d97706" opacity=".13"/>
|
||||
<text x="1026.0" y="1720" text-anchor="middle" font-size="13" font-weight="800" fill="#d97706">PROOF OF CONCEPT</text>
|
||||
<rect x="1184.0" y="1608" width="236" height="130" rx="14" fill="#fff" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="1302.0" y="1630" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">METHOD 5</text>
|
||||
<text x="1302.0" y="1662" text-anchor="middle" font-size="21" font-weight="800" fill="#0f172a">Desktop</text>
|
||||
<text x="1302.0" y="1688" text-anchor="middle" font-size="15" fill="#475569">Tauri</text>
|
||||
<rect x="1224.0" y="1702" width="156" height="26" rx="13" fill="#d97706" opacity=".13"/>
|
||||
<text x="1302.0" y="1720" text-anchor="middle" font-size="13" font-weight="800" fill="#d97706">PROOF OF CONCEPT</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -1,33 +0,0 @@
|
|||
# doxa-maps — slide deck outline (BLUF; hand-off pitch)
|
||||
|
||||
> One slide = one gold nugget. Bottom line up front, then facets. Real product names named.
|
||||
> Keep the itch words: JAMstack · SPA · embed on thousands of partner sites.
|
||||
|
||||
1. **Title** — "doxa-maps — a JAMstack bundler for composable, embeddable-first maps."
|
||||
Sub: build once, embed on thousands of partner sites, or ship as an app. Authored agent-first.
|
||||
2. **The bottom line** (the whole pitch on one slide) — One build step outputs every map as a
|
||||
self-contained web component with its own HTML page → embed anywhere, compose into a
|
||||
single-page app, or wrap as iOS/Android. [pipeline diagram from README]
|
||||
3. **The problem** — 10 partner sites, 3 bad options (rebuild per site / iframe farm / framework
|
||||
lock-in). Change once → redo ten times.
|
||||
4. **The answer: map islands** — one file, drops in like an image, host needs no framework or
|
||||
build step. [hero diagram]
|
||||
5. **One build, three products** — embeds · SPA (the power app) · mobile. Same bundles.
|
||||
6. **Proof it's portable** (the WordPress story) — the whole site changed frameworks; the map
|
||||
moved with a few-line wrapper, zero refactor. Migration-proof by design.
|
||||
7. **JAMstack = future-proof** — front end moves anywhere; swap the back end (or map engine
|
||||
Mapbox↔MapLibre) with no rebuild. Live: partners get new maps on reload.
|
||||
8. **Fast & lean** — one inlined file (Vue+CSS), tree-shaken, one request; faster than Vue-CDN.
|
||||
9. **The one rule** (the entire learning curve) — non-reusable code stays in your app-profile;
|
||||
only proven components graduate to the shared library. Follow it → maps never break each other.
|
||||
10. **Built for agents** — connect your agent → dev server → staging of every map → feedback →
|
||||
one build. "Built by agentic engineers, for agentic engineers." (aka vibe coders.)
|
||||
11. **The mission fit** — prayer maps embedded on partner sites; MapLibre = token-free, no single
|
||||
point of failure across thousands of embeds; posters + prayer packets print from one codebase.
|
||||
12. **Where it thrives** (the pairing, not a dependency) — decoupled by design; pair with an
|
||||
internal developer platform (MCP onboarding, multi-tenant, CI/CD) and the multiplication
|
||||
compounds. Standalone today; exponential paired.
|
||||
13. **The stack** (name the real products) — Vite · Bun · Vue web components · MapLibre/Mapbox ·
|
||||
Capacitor · Forgejo (source-control-as-CDN). No lock-in — un-closable primitives.
|
||||
14. **Try it / take it** — the repo, the docs (why → tutorial → how-to → reference → architecture),
|
||||
the live plugin gallery. Deep-dive: point your agent at documentation/architecture/.
|
||||
|
|
@ -21,7 +21,7 @@ mkdir -p "$(dirname "$LOCK_FILE")"
|
|||
|
||||
# ── THE COORDINATOR-ONLY GUARD ───────────────────────────────────────────────────────────────
|
||||
# ★ WHY A GUARD AND NOT A DOCUMENTED RULE (measured 2026-09-23, the third instance of one shape)
|
||||
# "Agents never run `bun run build`" is ALREADY a top-level heading in AGENT-BRIEF.md, and the
|
||||
# "Agents never run `bun run build`" is ALREADY a top-level rule in the contributor guidance, and the
|
||||
# research doc that established it is three hours old. It was violated anyway — by the
|
||||
# coordinator, who put `bun run build` into a dispatch prompt while telling the same agent to
|
||||
# read the brief first. The agent obeyed the prompt, the build's clean step wiped public/ mid-
|
||||
|
|
|
|||
30
internal/check-map-unmount-guard.mjs
Normal file
30
internal/check-map-unmount-guard.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* check-map-unmount-guard — the fast, browser-free half of the unmount-race proof (by gospel-plugin,
|
||||
adopted into internal/ by the studio lane; the end-to-end half is check-map-unmount-mid-init.mjs).
|
||||
|
||||
Proves the guard by REPRODUCING the race against the real file: take the shipped source,
|
||||
run its ensureEngineCSS with a null element (what an unmounted pane hands it), and do the
|
||||
same against a copy with the guard stripped. One must throw, the other must not — a fix
|
||||
that cannot be shown to matter is not evidence. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
const SRC = readFileSync('library/composables/useMapInstance.js', 'utf8')
|
||||
const grab = (src) => {
|
||||
const i = src.indexOf('async function ensureEngineCSS')
|
||||
const j = src.indexOf('\n}\n', i)
|
||||
return src.slice(i, j + 2)
|
||||
}
|
||||
const body = grab(SRC)
|
||||
const stripped = body.replace(' if (!element) return\n', '')
|
||||
if (stripped === body) { console.error('CONTROL BROKEN — the guard line was not found, so the negative arm proves nothing.'); process.exit(1) }
|
||||
|
||||
const run = async (fnSrc) => {
|
||||
const f = new Function('ShadowRoot', `${fnSrc}; return ensureEngineCSS`)(class ShadowRoot {})
|
||||
try { await f(null, 'https://example.invalid/x.css'); return 'resolved' } catch (e) { return `threw: ${e.message}` }
|
||||
}
|
||||
const withGuard = await run(body)
|
||||
const without = await run(stripped)
|
||||
console.log(` guarded -> ${withGuard}`)
|
||||
console.log(` unguarded -> ${without}`)
|
||||
const ok = withGuard === 'resolved' && /getRootNode/.test(without)
|
||||
console.log(ok ? 'UNMOUNT-RACE: PASS — the guard turns studio\'s crash into a clean return.'
|
||||
: 'UNMOUNT-RACE: FAIL — the arms did not separate; this proves nothing.')
|
||||
process.exit(ok ? 0 : 1)
|
||||
86
internal/check-map-unmount-mid-init.mjs
Normal file
86
internal/check-map-unmount-mid-init.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-map-unmount-mid-init — a map whose pane is removed WHILE it is still initialising must not throw.
|
||||
*
|
||||
* USAGE: node internal/check-map-unmount-mid-init.mjs --url http://localhost:5299/
|
||||
* Graded by EXIT CODE: 0 = no map threw, 1 = a map threw, 2 = UNMEASURED (the race was not staged).
|
||||
*
|
||||
* ★ THE DEFECT (found by check:studio-all, 1 run in 4, 2026-09-25):
|
||||
* Cannot read properties of null (reading 'getRootNode')
|
||||
* at ensureEngineCSS (library/composables/useMapInstance.js)
|
||||
* <- initializeMap (library/composables/useMapInstance.js)
|
||||
* <- DashboardMapPanel.vue
|
||||
* initializeMap is async. If its component unmounts during an await (a studio tab switch, a
|
||||
* composite swapping a view), Vue nulls the container ref, and the next line dereferenced it.
|
||||
*
|
||||
* ★ WHY 1-IN-N IS NOT PROOF, AND HOW THIS MAKES IT DETERMINISTIC. The window is the time the engine
|
||||
* script takes to load, so whether a tab switch lands inside it is luck. This gate removes the
|
||||
* luck: it HOLDS the engine script request, opens a map plugin (its initializeMap suspends on the
|
||||
* held script), switches the studio to a non-map plugin (which destroys the map pane — map rows are
|
||||
* torn down on deactivate), and only then releases the script. Every run lands in the window.
|
||||
* Negative-tested: fails every run on the unguarded composable, passes on the guarded one.
|
||||
*
|
||||
* It refuses to pass vacuously: if the script was never requested (preloaded) or the pane was not
|
||||
* actually removed before release, the race was not staged and it exits 2, not 0.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const i = process.argv.indexOf('--url')
|
||||
const BASE = (i > -1 ? process.argv[i + 1] : '').replace(/#.*$/, '')
|
||||
if (!BASE) { console.error('check-map-unmount-mid-init: pass --url <studio page>'); process.exit(2) }
|
||||
|
||||
// Production map plugins that go through useMapInstance, opened by their studio key.
|
||||
const CASES = [
|
||||
{ key: 'doxa-research-map/research-map', tag: 'doxa-research-map' },
|
||||
{ key: 'gospel-resources/gospel-dashboard', tag: 'gospel-resources-map' },
|
||||
{ key: 'doxa-simple-map/doxa-simple-map', tag: 'doxa-map' }
|
||||
]
|
||||
const AWAY = 'doxa-vision-card' // a non-map plugin to switch to
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
let failed = 0, unmeasured = 0
|
||||
for (const c of CASES) {
|
||||
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(String(e.message || e).slice(0, 160) + ' [' + String(e.stack || '').split('\n').slice(1, 3).map((l) => l.trim().replace(/https?:\/\/[^/]+/, '')).join(' <- ') + ']'))
|
||||
page.on('console', (m) => { if (m.type() === 'error' && /useMapInstance|getRootNode|ZERO dimensions/.test(m.text())) errors.push('console: ' + m.text().slice(0, 160)) })
|
||||
|
||||
let release = null, held = false
|
||||
const heldNow = new Promise((resolve) => {
|
||||
page.route(/mapbox-gl\.js(\?|$)/, async (route) => {
|
||||
held = true
|
||||
resolve()
|
||||
await new Promise((r) => { release = r })
|
||||
await route.continue()
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(BASE + '#/' + c.key, { waitUntil: 'domcontentloaded' })
|
||||
const got = await Promise.race([heldNow.then(() => true), new Promise((r) => setTimeout(() => r(false), 30000))])
|
||||
if (!got) { console.log('UNMEASURED ' + c.key + ' — the engine script was never requested (preloaded?)'); unmeasured++; await page.close(); continue }
|
||||
|
||||
// Switch away while initializeMap is suspended on the held script.
|
||||
await page.waitForFunction(() => {
|
||||
const s = document.querySelector('plugin-studio')
|
||||
return !!(s && s.shadowRoot && s.shadowRoot.querySelector('.st-row[aria-level="2"]'))
|
||||
}, null, { timeout: 30000 })
|
||||
await page.evaluate((away) => {
|
||||
const rows = [...document.querySelector('plugin-studio').shadowRoot.querySelectorAll('.st-row[aria-level="2"]')]
|
||||
const r = rows.find((x) => x.querySelector('.st-label').textContent.trim() === away)
|
||||
if (r) r.click()
|
||||
}, AWAY)
|
||||
await page.waitForTimeout(600)
|
||||
const stillMounted = await page.evaluate((tag) => !!document.querySelector('plugin-studio').shadowRoot.querySelector('.ps-slot ' + tag), c.tag)
|
||||
if (stillMounted) { console.log('UNMEASURED ' + c.key + ' — its pane was not removed before the script was released'); unmeasured++; release(); await page.close(); continue }
|
||||
|
||||
release()
|
||||
await page.waitForTimeout(4000)
|
||||
if (!held) unmeasured++
|
||||
if (errors.length) { failed++; console.log('FAIL ' + c.key + '\n ' + [...new Set(errors)].join('\n ')) }
|
||||
else console.log('ok ' + c.key + ' — unmounted mid-initialise, released the engine, no error')
|
||||
await page.close()
|
||||
}
|
||||
await browser.close()
|
||||
if (failed) { console.log('MAP-UNMOUNT-MID-INIT: FAIL — ' + failed + ' of ' + CASES.length + ' maps threw after unmounting mid-initialise'); process.exit(1) }
|
||||
if (unmeasured) { console.log('MAP-UNMOUNT-MID-INIT: UNMEASURED — ' + unmeasured + ' case(s) did not stage the race'); process.exit(2) }
|
||||
console.log('MAP-UNMOUNT-MID-INIT: PASS — ' + CASES.length + ' maps unmounted mid-initialise without an error')
|
||||
|
|
@ -64,6 +64,13 @@ const NEIGHBOUR_KNOWN = {
|
|||
// owner, and is deleted in the SAME commit as the fix, so main never carries a stale exemption
|
||||
// and never red-flags a bug that is already fixed.
|
||||
}
|
||||
// KNOWN, ROUTED gaps in a composite's internal link (not measurable as pass/fail yet: no DOM hook).
|
||||
const LINKED_KNOWN = {
|
||||
// upg-100-mfe.vue:143-148 answers `doxa-upg-100-list:show-on-map` with go('map') only; it never
|
||||
// re-dispatches `doxa:select-zone` (the event upg-100-list.vue:1366-1381 isolates a zone on), so
|
||||
// "Show on map" scrolls to the map without selecting that list's zone. Routed 2026-09-25.
|
||||
'upg-100: "Show on map" does not select the zone on the map': 'upg100-plugin (upg-100-mfe.vue:143 re-dispatch doxa:select-zone)',
|
||||
}
|
||||
const MAPBOX_KNOWN = {
|
||||
// EMPTY ON PURPOSE. The one entry here — my-upg-100-list/upg-100-list.vue:1464 pinning
|
||||
// mapbox-gl.css v3.3.0 regardless of engine — was fixed and the exemption deleted in the SAME
|
||||
|
|
@ -239,6 +246,71 @@ try {
|
|||
}
|
||||
checked.neighbour = ids.length
|
||||
await page.close()
|
||||
|
||||
// ── LINKED PHASE — matrix cell D for the composites: "a filter in one part updates the other",
|
||||
// driven INSIDE a gallery tab (the composites' own leaf pages are covered by verify:interop; this
|
||||
// proves the link survives being nested one level deeper, in the gallery's document). Each check
|
||||
// clicks the composite's REAL control and polls an observable in the OTHER part. Playwright CSS
|
||||
// pierces open shadow roots, so one locator reaches through the gallery and the composite.
|
||||
// Mechanics (found 2026-09-25, file:line in each owner's plugin):
|
||||
// Big Picture dashboard facet → `dashboard-filter` emit → grid props; the grid's count
|
||||
// line (.repel) is ABSENT until a filter applies, then reads "Showing N".
|
||||
// Simple Research the two halves are INDEPENDENT BY DESIGN (research-map-plus-grid.vue:49-56),
|
||||
// so D asserts each half narrows on its own control — the grid's search.
|
||||
// Regions list `region-selected` → window `doxa-regions-dashboard:select` → the
|
||||
// dashboard's h1.rd-title becomes the region.
|
||||
// UPG-100 "Show on map" must bring the map; selecting the ZONE on it is a routed gap.
|
||||
mode = 'js'
|
||||
const lp = await browser.newPage({ viewport: { width: 1400, height: 900 } })
|
||||
await lp.goto(`http://localhost:${HOST_PORT}/`)
|
||||
const lbooted = await lp.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false)
|
||||
const pane = () => lp.locator('doxa-maps-spa section.dms-pane:not([hidden])')
|
||||
const open = async (id) => {
|
||||
await lp.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id)
|
||||
await lp.waitForFunction(id => { const vm = document.querySelector('doxa-maps-spa')._instance.proxy; const t = vm.tabs.find(x => x.id === id); return t && t.state === 'ready' }, id, { timeout: 60000 })
|
||||
await lp.waitForTimeout(6000) // let the composite fetch + paint its data before touching it
|
||||
}
|
||||
const poll = async (fn, ms = 25000) => { const end = Date.now() + ms; let v; while (Date.now() < end) { v = await fn().catch(() => null); if (v && v.ok) return v; await lp.waitForTimeout(700) } return v || { ok: false, why: 'no observation' } }
|
||||
const LINKS = {
|
||||
'dashboard-plus-grid': async () => {
|
||||
const count = pane().locator('.repel').filter({ hasText: /Showing/ })
|
||||
const before = await count.count()
|
||||
const facet = pane().locator('[data-facet="wagf_region"][data-value]:not(.dash-only)').first()
|
||||
const val = await facet.getAttribute('data-value', { timeout: 20000 })
|
||||
await facet.click()
|
||||
return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; return { ok: before === 0 && /Showing\s+[\d,]+/.test(txt), why: `facet wagf_region=${val} → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } })
|
||||
},
|
||||
'research-map-plus-grid': async () => {
|
||||
const total = pane().locator('.repel').first()
|
||||
const num = async () => Number(((await total.innerText({ timeout: 20000 })).match(/[\d,]+/) || ['0'])[0].replace(/,/g, ''))
|
||||
const before = await num()
|
||||
await pane().locator('.pgg-search input').first().fill('Kurd')
|
||||
return poll(async () => { const after = await num(); return { ok: before > 0 && after > 0 && after < before, why: `grid search "Kurd": ${before} → ${after}` } })
|
||||
},
|
||||
'regions-dashboard': async () => {
|
||||
const btn = pane().locator('button.region-card__link').first()
|
||||
const name = (await btn.innerText({ timeout: 20000 })).trim()
|
||||
const title = pane().locator('h1.rd-title').first()
|
||||
const before = (await title.innerText({ timeout: 20000 }).catch(() => '')).trim()
|
||||
await btn.click()
|
||||
return poll(async () => { const t = (await title.innerText()).trim(); return { ok: t !== before && t.includes(name), why: `list "${name}" → dashboard title "${before}" → "${t}"` } })
|
||||
},
|
||||
'upg-100': async () => {
|
||||
const tape = pane().locator('.card[data-list-id] button.tape.tape-h').first()
|
||||
await tape.click({ timeout: 20000 })
|
||||
return poll(async () => { const on = pane().locator('.viewsel button[aria-pressed="true"]'); const txt = (await on.first().innerText()).trim(); return { ok: /map/i.test(txt) || /карт|mapa|carte|地图|خريطة|मानचित्र|karte|mappa|hartă/i.test(txt), why: `"Show on map" → active view "${txt}"` } })
|
||||
}
|
||||
}
|
||||
if (!lbooted) fails.push('[linked] the gallery did not boot within 60s')
|
||||
for (const [id, run] of Object.entries(lbooted ? LINKS : {})) {
|
||||
let r
|
||||
try { await open(id); r = await run() } catch (e) { r = { ok: false, why: 'threw: ' + String(e.message || e).split('\n')[0].slice(0, 160) } }
|
||||
console.log(`${r.ok ? 'PASS' : 'FAIL'} [linked] ${id}: ${r.why}`)
|
||||
if (!r.ok) fails.push(`[linked] ${id}: ${r.why}`)
|
||||
}
|
||||
for (const [k, owner] of Object.entries(LINKED_KNOWN)) console.log(`KNOWN [linked] ${k} — owner: ${owner}`)
|
||||
checked.linked = Object.keys(LINKS).length
|
||||
await lp.close()
|
||||
}
|
||||
} finally { await browser.close(); cleanup() }
|
||||
|
||||
|
|
@ -246,7 +318,7 @@ try {
|
|||
// this entry in an inline # comment, so an appended --mode landed inside the comment) must not
|
||||
// read as "both modes green": each requested mode has to have driven at least one tab.
|
||||
for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`)
|
||||
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
|
||||
const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : []), ...(checked.linked ? ['linked'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ')
|
||||
if (fails.length) {
|
||||
console.log(`\nchecked: ${tally}`)
|
||||
console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,25 @@ const MARKERS = [
|
|||
landed: 'regions, c4c60a4 and earlier',
|
||||
why: 'accessible names + aria-expanded on the legend row caret and clear chip',
|
||||
},
|
||||
{
|
||||
// A CONTROL-FLOW fix has no literal to grep, so this entry uses REGEX markers (see `has` below).
|
||||
// The built shape keeps its structure through minification even though names change:
|
||||
// unguarded const o=e.getRootNode();if(!(o instanceof ShadowRoot))
|
||||
// guarded if(!e)return;const o=e.getRootNode();if(!(o instanceof ShadowRoot))
|
||||
// The `instanceof ShadowRoot` tail pins it to ensureEngineCSS: useShadowStyles has its own
|
||||
// (older) `if(!a)return;…getRootNode()` guard, which must not count.
|
||||
id: 'map-unmount-guard',
|
||||
component: 'library/composables/useMapInstance.js',
|
||||
sourceRe: /if \(!element\) return\n\s*const root = element\.getRootNode\(\)/,
|
||||
builtRe: /if\(!([\w$]+)\)return;const ([\w$]+)=\1\.getRootNode\(\);if\(!\(\2 instanceof ShadowRoot\)\)/,
|
||||
marker: 'if (!element) return (ensureEngineCSS)',
|
||||
landed: 'studio, 2026-09-25',
|
||||
why: 'a map whose pane unmounts mid-initialise no longer throws "reading getRootNode" (check-map-unmount-mid-init)',
|
||||
},
|
||||
]
|
||||
// A marker is a literal (`marker`, matched with includes) unless the entry supplies regexes.
|
||||
const inSource = (m, src) => (m.sourceRe ? m.sourceRe.test(src) : src.includes(m.marker))
|
||||
const inBuilt = (m, js) => (m.builtRe ? m.builtRe.test(js) : js.includes(m.marker))
|
||||
|
||||
if (!existsSync(TREE)) {
|
||||
console.log(`SHARED-REACHED-BUNDLES: SKIP — no built tree at ${TREE}.`)
|
||||
|
|
@ -125,7 +143,7 @@ for (const m of MARKERS) {
|
|||
continue
|
||||
}
|
||||
const src = readFileSync(srcPath, 'utf8')
|
||||
if (!src.includes(m.marker)) {
|
||||
if (!inSource(m, src)) {
|
||||
// THE MARKER IS GONE FROM SOURCE. That is a stale ENTRY, not a stale bundle — failing every
|
||||
// consumer here would blame the bundles for a change that was legitimately reverted.
|
||||
staleMarkers.push(`${m.id}: marker "${m.marker}" is no longer in ${m.component} — `
|
||||
|
|
@ -141,7 +159,7 @@ for (const m of MARKERS) {
|
|||
const built = join(TREE, bundle, `${bundle}.js`)
|
||||
if (!existsSync(built)) { unbuilt.push(bundle); continue }
|
||||
const js = readFileSync(built, 'utf8')
|
||||
;(js.includes(m.marker) ? have : missing).push(bundle)
|
||||
;(inBuilt(m, js) ? have : missing).push(bundle)
|
||||
}
|
||||
|
||||
console.log(` ${m.id} — ${m.why}`)
|
||||
|
|
|
|||
112
internal/check-studio-every-plugin.mjs
Normal file
112
internal/check-studio-every-plugin.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-studio-every-plugin — open EVERY plugin in the plugin studio and fail on any that is broken.
|
||||
*
|
||||
* USAGE: bun run check:studio-all -- --url http://localhost:5299/ (dev server)
|
||||
* bun run check:studio-all -- --url http://localhost:5478/index.html (built tree, serve-strict)
|
||||
* Graded by EXIT CODE: 0 = every plugin mounted with content, 1 = at least one failed.
|
||||
*
|
||||
* ★ WHY THIS EXISTS WHEN smoke:studio ALREADY DOES. smoke:studio samples 4 tabs by index. On
|
||||
* 2026-09-25 a first-run sweep of all 35 found people-groups-grid rendering 2 DOM nodes and a page
|
||||
* error — standalone, in the studio and inside two composites — and 13 plugins mounting with
|
||||
* their data 404ing on a fresh clone. The sample had passed. A plugin the gift ships must be
|
||||
* checked, not sampled; this takes ~2 minutes.
|
||||
*
|
||||
* WHAT FAILS A PLUGIN (each measured on a real defect, none guessed):
|
||||
* - the studio's own error overlay ("Could not load …") — the element never registered
|
||||
* - a page error while that plugin was active (the i18n crash above)
|
||||
* - a failed request (4xx/5xx) while it was active — a data file that is missing is a plugin
|
||||
* that looks empty. Mapbox tile/event hosts are excluded: a URL-restricted token 403s there
|
||||
* and that is a deployment setting, not a plugin defect.
|
||||
* - a mount with almost nothing in it (< 8 elements and < 50 characters): the blank grid was 2.
|
||||
*
|
||||
* --url is REQUIRED. Several gates here hardcode :5173, and a drifted dev server made them measure
|
||||
* another worktree's tree — so this one refuses to guess.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const i = process.argv.indexOf('--url')
|
||||
const URL_ = i > -1 ? process.argv[i + 1] : ''
|
||||
if (!URL_) {
|
||||
console.error('check-studio-every-plugin: pass --url <studio page>, e.g. --url http://localhost:5299/')
|
||||
process.exit(2)
|
||||
}
|
||||
const PER_PLUGIN_MS = Number(process.env.STUDIO_ALL_WAIT_MS || 16000)
|
||||
const IGNORE_HOSTS = /(^|\.)(api\.mapbox\.com|events\.mapbox\.com|tiles\.mapbox\.com)$/
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage()
|
||||
let current = '(page load)'
|
||||
const seen = {}
|
||||
const note = (kind, v) => { const s = (seen[current] = seen[current] || { errors: [], failed: [] }); s[kind].push(v) }
|
||||
// Keep the top of the STACK, not just the message: a message like "reading 'getRootNode'" names no
|
||||
// caller, and the owner cannot fix it without knowing which call site threw (asked 2026-09-25).
|
||||
page.on('pageerror', (e) => {
|
||||
const frames = String(e.stack || '').split('\n').slice(1, 5).map((l) => l.trim().replace(/https?:\/\/[^/]+/, '').replace(/\?v=[0-9a-f]+/, '')).join(' <- ')
|
||||
note('errors', String(e.message || e).slice(0, 160) + (frames ? ' [' + frames + ']' : ''))
|
||||
})
|
||||
page.on('response', (r) => {
|
||||
if (r.status() < 400) return
|
||||
let host = ''
|
||||
try { host = new URL(r.url()).hostname } catch (_) { /* keep '' */ }
|
||||
if (IGNORE_HOSTS.test(host)) return
|
||||
note('failed', r.status() + ' ' + r.url().replace(/^https?:\/\/[^/]+/, '').slice(0, 100))
|
||||
})
|
||||
|
||||
await page.goto(URL_, { waitUntil: 'networkidle' })
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const s = document.querySelector('plugin-studio')
|
||||
return !!(s && s.shadowRoot && s.shadowRoot.querySelector('.st-row[aria-level="2"]'))
|
||||
}, null, { timeout: 30000 })
|
||||
} catch (_) {
|
||||
console.error('STUDIO-ALL: FAIL — no <plugin-studio> with a plugin tree at ' + URL_)
|
||||
await browser.close(); process.exit(1)
|
||||
}
|
||||
|
||||
// Every PLUGIN row (aria-level 2 = a plugin in its capability section; deeper rows are parts that
|
||||
// open one of these same plugins, and level 1 is a section header).
|
||||
const rows = await page.evaluate(() => [...document.querySelector('plugin-studio').shadowRoot
|
||||
.querySelectorAll('.st-row[aria-level="2"]')].map((r) => r.querySelector('.st-label').textContent.trim() + ' <' + r.querySelector('.st-desc').textContent.trim() + '>'))
|
||||
|
||||
const results = []
|
||||
for (let n = 0; n < rows.length; n++) {
|
||||
current = rows[n]
|
||||
await page.evaluate((n) => document.querySelector('plugin-studio').shadowRoot.querySelectorAll('.st-row[aria-level="2"]')[n].click(), n)
|
||||
let r = null
|
||||
const deadline = Date.now() + PER_PLUGIN_MS
|
||||
// Poll until the pane has real content, errors, or time runs out — a fixed sleep would either be
|
||||
// too slow for the whole run or too short for a map.
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(400)
|
||||
r = await page.evaluate(() => {
|
||||
const s = document.querySelector('plugin-studio').shadowRoot
|
||||
const pane = [...s.querySelectorAll('.ps-pane')].find((x) => !x.hidden)
|
||||
const hint = pane && pane.querySelector('.ps-hint')
|
||||
const host = pane && pane.querySelector('.ps-slot') && pane.querySelector('.ps-slot').children[0]
|
||||
let nodes = 0
|
||||
const walk = (root) => { for (const e of root.querySelectorAll('*')) { nodes++; if (e.shadowRoot) walk(e.shadowRoot) } }
|
||||
if (host) walk(host.shadowRoot || host)
|
||||
const text = host ? ((host.shadowRoot || host).textContent || '').replace(/\s+/g, ' ').trim().length : 0
|
||||
return { hint: hint ? hint.textContent.replace(/\s+/g, ' ').trim().slice(0, 140) : '', nodes, text }
|
||||
})
|
||||
if (r.hint.startsWith('Could not') || r.nodes > 40) break
|
||||
}
|
||||
const s = seen[current] || { errors: [], failed: [] }
|
||||
const why = []
|
||||
if (r.hint && !r.hint.startsWith('Loading')) why.push(r.hint)
|
||||
if (r.nodes < 8 && r.text < 50) why.push('mounted almost nothing (' + r.nodes + ' elements)')
|
||||
if (s.errors.length) why.push('page error: ' + [...new Set(s.errors)].join(' / '))
|
||||
if (s.failed.length) why.push('failed requests: ' + [...new Set(s.failed)].slice(0, 3).join(' , '))
|
||||
results.push({ row: current, nodes: r.nodes, why })
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
const bad = results.filter((x) => x.why.length)
|
||||
for (const x of results) console.log((x.why.length ? 'FAIL ' : 'ok ') + x.row.padEnd(60) + (x.why.length ? ' ' + x.why.join(' | ') : ' ' + x.nodes + ' elements'))
|
||||
const loadErr = seen['(page load)']
|
||||
if (loadErr && (loadErr.errors.length || loadErr.failed.length)) console.log('page load: ' + [...loadErr.errors, ...loadErr.failed].slice(0, 4).join(' | '))
|
||||
console.log(bad.length
|
||||
? 'STUDIO-ALL: FAIL — ' + bad.length + ' of ' + results.length + ' plugins broken at ' + URL_
|
||||
: 'STUDIO-ALL: PASS — all ' + results.length + ' plugins mounted with content, no page errors, no failed requests, at ' + URL_)
|
||||
process.exit(bad.length ? 1 : 0)
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
* the build still blocks the gates that read its output.
|
||||
*
|
||||
* ★ WHY THERE IS NO TIMEOUT HERE
|
||||
* AGENT-BRIEF.md: "A command killed by a short `timeout` is not a failure." A runner that
|
||||
* House rule: "A command killed by a short `timeout` is not a failure." A runner that
|
||||
* imposed its own deadline would manufacture failures under exactly the load that makes
|
||||
* parallelism worth having. Steps run to completion; the caller decides.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ if (!/<plugin-studio\b/.test(html)) {
|
|||
if (/One shared core|paste-anywhere maps|scroll the strip/i.test(html)) {
|
||||
fail(`${BASE}/ is serving a STAGING GALLERY, not the studio.`,
|
||||
' That page is a chooser: it renders real-looking content and mounts nothing. See\n' +
|
||||
' _archive/staging-surface-2026-09-22/WHY-THIS-WAS-ARCHIVED.md.')
|
||||
' the project history (the staging surface was retired 2026-09-22).')
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ defineEmits(['select'])
|
|||
//
|
||||
// NO BACKTICKS ANYWHERE IN THIS STRING, including in comments: one stray backtick
|
||||
// terminates the JS template literal and the build stops compiling. Hence string
|
||||
// concatenation rather than one template literal (house style, AGENT-BRIEF.md).
|
||||
// concatenation rather than one template literal (house style).
|
||||
useShadowStyles(
|
||||
// Metrics copied from the shell's .viewsel; colours come from the ui-* custom
|
||||
// properties the host element already declares, with the material-light palette
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ export async function ensureEngineScript(provider) {
|
|||
* - Always resolves — never hangs (2 s timeout fallback).
|
||||
*/
|
||||
async function ensureEngineCSS(element, cssUrl) {
|
||||
// An unmounted pane hands us null (see initializeMap's liveness checks); nothing to style.
|
||||
if (!element) return
|
||||
const root = element.getRootNode()
|
||||
if (!(root instanceof ShadowRoot)) return // light DOM — wrapper <link> is enough
|
||||
|
||||
|
|
@ -367,8 +369,17 @@ export function useMapInstance({
|
|||
return
|
||||
}
|
||||
|
||||
// ★ LIVENESS AFTER EVERY AWAIT. initializeMap is async, and its component can unmount while it
|
||||
// waits — a studio tab switch, a composite swapping views. Vue then nulls containerRef, and the
|
||||
// next line that touched it threw "Cannot read properties of null (reading 'getRootNode')"
|
||||
// (found 1 run in 4 by check:studio-all, 2026-09-25; made deterministic by
|
||||
// internal/check-map-unmount-mid-init.mjs, which holds the engine script and switches away).
|
||||
// An unmounted pane is normal, not an error, so these returns are SILENT.
|
||||
const gone = () => !containerRef.value
|
||||
|
||||
// Load the selected engine (no-op if index.html preloaded it).
|
||||
await ensureEngineScript(provider)
|
||||
if (gone()) return
|
||||
const gl = engineGlobal(provider)
|
||||
if (!gl) {
|
||||
console.error(`[useMapInstance] ${isMaplibre ? 'maplibregl' : 'mapboxgl'} is not available. `
|
||||
|
|
@ -398,11 +409,13 @@ export function useMapInstance({
|
|||
// Wait for the engine CSS inside the shadow root before creating the Map.
|
||||
// Without this, _detectMissingCSS fires and the canvas initializes at 0px.
|
||||
await ensureEngineCSS(containerRef.value, isMaplibre ? ENGINES.maplibre.css : MAPBOX_CSS_URL)
|
||||
if (gone()) return
|
||||
|
||||
// Wait one animation frame so the browser has laid out the container.
|
||||
// Mapbox reads clientWidth/clientHeight at init — if layout hasn't settled
|
||||
// yet the canvas initializes at 0×0 and nothing renders.
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
if (gone()) return
|
||||
|
||||
// ─── Dimension diagnostics ───────────────────────────────────────
|
||||
const el = containerRef.value
|
||||
|
|
|
|||
102
library/i18n/locales/ar/gospel.json
Normal file
102
library/i18n/locales/ar/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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": "لديها رابط",
|
||||
"doxa0": "لدى الجميع شيء",
|
||||
"doxa1": "1–4 بلا شيء",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 لديهم موارد",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "شعوب دوكسا على هذه الخريطة",
|
||||
"notPlaced": "ضمن القائمة، بلا موقع بعد",
|
||||
"outOfScope": "لا توجد شعوب دوكسا — غير ملوَّنة"
|
||||
},
|
||||
"tabs": {
|
||||
"groups": "أكبر الشعوب",
|
||||
"countries": "حسب البلد",
|
||||
"regions": "حسب المنطقة",
|
||||
"noprayer": "لا أحد يصلّي",
|
||||
"hnr": "يسمعون، بلا نص"
|
||||
},
|
||||
"stat": {
|
||||
"language": "اللغة",
|
||||
"population": "عدد السكان",
|
||||
"countries": "البلدان",
|
||||
"peopleGroups": "الشعوب",
|
||||
"noResource": "لا يوجد أي مورد",
|
||||
"peopleAffected": "الأشخاص المتأثرون",
|
||||
"haveScripture": "لديهم الكتاب المقدس",
|
||||
"haveFilm": "لديهم الفيلم",
|
||||
"haveAudio": "لديهم كتاب مقدس مسموع",
|
||||
"signLanguages": "لغات الإشارة",
|
||||
"growingUnreached": "متزايدون وغير مبلَّغين",
|
||||
"deathsDay": "الوفيات المقدَّرة يوميًا"
|
||||
},
|
||||
"sheet": {
|
||||
"ownLanguage": "بلغتهم الأم",
|
||||
"close": "إغلاق",
|
||||
"loading": "جارٍ تحميل الموارد…",
|
||||
"pray": "صلِّ من أجل",
|
||||
"everyResource": "كل الموارد بـ",
|
||||
"alsoSpeak": "يتحدثون أيضًا"
|
||||
},
|
||||
"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": "لم يُتحقق منه",
|
||||
"linkDead": "رابط معطّل",
|
||||
"wrongLanguage": "لغة غير صحيحة",
|
||||
"varietyNotStated": "(اللهجة غير محددة)",
|
||||
"inLanguage": "بـ"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/de/gospel.json
Normal file
102
library/i18n/locales/de/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "alle haben etwas",
|
||||
"doxa1": "1–4 ohne",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 erreicht",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Doxa-Volksgruppen auf dieser Karte",
|
||||
"notPlaced": "auf der Liste, noch nicht verortet",
|
||||
"outOfScope": "keine Doxa-Volksgruppen — nicht eingefärbt"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Betet für",
|
||||
"everyResource": "Alle Ressourcen auf",
|
||||
"alsoSpeak": "Sprechen ebenfalls"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "Link tot",
|
||||
"wrongLanguage": "falsche Sprache",
|
||||
"varietyNotStated": "(Varietät nicht angegeben)",
|
||||
"inLanguage": "auf"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/en/gospel.json
Normal file
102
library/i18n/locales/en/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "all have something",
|
||||
"doxa1": "1–4 with nothing",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 reached",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Doxa people groups on this map",
|
||||
"notPlaced": "on the roster, not yet placed",
|
||||
"outOfScope": "no Doxa people groups — not coloured"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Pray for",
|
||||
"everyResource": "Every resource in",
|
||||
"alsoSpeak": "Also speak"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "link dead",
|
||||
"wrongLanguage": "wrong language",
|
||||
"varietyNotStated": "(variety not stated)",
|
||||
"inLanguage": "in"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/es/gospel.json
Normal file
102
library/i18n/locales/es/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "todos tienen algo",
|
||||
"doxa1": "1–4 sin nada",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 alcanzados",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Pueblos de Doxa en este mapa",
|
||||
"notPlaced": "en la lista, aún sin ubicar",
|
||||
"outOfScope": "sin pueblos de Doxa — sin color"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Ora por",
|
||||
"everyResource": "Todos los recursos en",
|
||||
"alsoSpeak": "También hablan"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "enlace roto",
|
||||
"wrongLanguage": "idioma incorrecto",
|
||||
"varietyNotStated": "(variedad no indicada)",
|
||||
"inLanguage": "en"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/fr/gospel.json
Normal file
102
library/i18n/locales/fr/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "tous ont quelque chose",
|
||||
"doxa1": "1–4 sans rien",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 atteints",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Peuples Doxa sur cette carte",
|
||||
"notPlaced": "sur la liste, pas encore localisés",
|
||||
"outOfScope": "aucun peuple Doxa — non coloré"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Priez pour",
|
||||
"everyResource": "Toutes les ressources en",
|
||||
"alsoSpeak": "Parlent aussi"
|
||||
},
|
||||
"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é",
|
||||
"linkDead": "lien mort",
|
||||
"wrongLanguage": "mauvaise langue",
|
||||
"varietyNotStated": "(variété non précisée)",
|
||||
"inLanguage": "en"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/hi/gospel.json
Normal file
102
library/i18n/locales/hi/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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": "के पास लिंक है",
|
||||
"doxa0": "सभी के पास कुछ है",
|
||||
"doxa1": "1–4 के पास कुछ नहीं",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 तक पहुँच",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "इस मानचित्र पर दोक्सा जनसमूह",
|
||||
"notPlaced": "सूची में, अभी स्थान नहीं",
|
||||
"outOfScope": "कोई दोक्सा जनसमूह नहीं — रंग नहीं"
|
||||
},
|
||||
"tabs": {
|
||||
"groups": "सबसे बड़े जनसमूह",
|
||||
"countries": "देश के अनुसार",
|
||||
"regions": "क्षेत्र के अनुसार",
|
||||
"noprayer": "कोई प्रार्थना नहीं कर रहा",
|
||||
"hnr": "सुन सकते हैं, पाठ नहीं"
|
||||
},
|
||||
"stat": {
|
||||
"language": "भाषा",
|
||||
"population": "जनसंख्या",
|
||||
"countries": "देश",
|
||||
"peopleGroups": "जनसमूह",
|
||||
"noResource": "कोई संसाधन नहीं मिला",
|
||||
"peopleAffected": "प्रभावित लोग",
|
||||
"haveScripture": "पवित्र शास्त्र उपलब्ध",
|
||||
"haveFilm": "फ़िल्म उपलब्ध",
|
||||
"haveAudio": "ऑडियो शास्त्र उपलब्ध",
|
||||
"signLanguages": "सांकेतिक भाषाएँ",
|
||||
"growingUnreached": "बढ़ते और अनपहुँचे",
|
||||
"deathsDay": "अनुमानित मृत्यु/दिन"
|
||||
},
|
||||
"sheet": {
|
||||
"ownLanguage": "उनकी अपनी भाषा में",
|
||||
"close": "बंद करें",
|
||||
"loading": "संसाधन लोड हो रहे हैं…",
|
||||
"pray": "के लिए प्रार्थना करें",
|
||||
"everyResource": "में सभी संसाधन",
|
||||
"alsoSpeak": "यह भी बोलते हैं"
|
||||
},
|
||||
"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": "अनजाँचा",
|
||||
"linkDead": "लिंक निष्क्रिय",
|
||||
"wrongLanguage": "ग़लत भाषा",
|
||||
"varietyNotStated": "(उपभाषा नहीं बताई गई)",
|
||||
"inLanguage": "में"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/it/gospel.json
Normal file
102
library/i18n/locales/it/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "tutti hanno qualcosa",
|
||||
"doxa1": "1–4 senza nulla",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 raggiunti",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Popoli Doxa su questa mappa",
|
||||
"notPlaced": "in elenco, non ancora collocati",
|
||||
"outOfScope": "nessun popolo Doxa — non colorato"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Prega per",
|
||||
"everyResource": "Tutte le risorse in",
|
||||
"alsoSpeak": "Parlano anche"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "link non attivo",
|
||||
"wrongLanguage": "lingua errata",
|
||||
"varietyNotStated": "(varietà non indicata)",
|
||||
"inLanguage": "in"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/pt/gospel.json
Normal file
102
library/i18n/locales/pt/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "todos têm algo",
|
||||
"doxa1": "1–4 sem nada",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 alcançados",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Povos Doxa neste mapa",
|
||||
"notPlaced": "na lista, ainda sem localização",
|
||||
"outOfScope": "sem povos Doxa — sem cor"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Ore por",
|
||||
"everyResource": "Todos os recursos em",
|
||||
"alsoSpeak": "Também falam"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "link inativo",
|
||||
"wrongLanguage": "idioma incorreto",
|
||||
"varietyNotStated": "(variedade não indicada)",
|
||||
"inLanguage": "em"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/ro/gospel.json
Normal file
102
library/i18n/locales/ro/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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",
|
||||
"doxa0": "toate au ceva",
|
||||
"doxa1": "1–4 fără nimic",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 atinse",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Popoare Doxa pe această hartă",
|
||||
"notPlaced": "pe listă, încă nelocalizate",
|
||||
"outOfScope": "niciun popor Doxa — necolorat"
|
||||
},
|
||||
"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…",
|
||||
"pray": "Roagă-te pentru",
|
||||
"everyResource": "Toate resursele în",
|
||||
"alsoSpeak": "Vorbesc și"
|
||||
},
|
||||
"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",
|
||||
"linkDead": "link mort",
|
||||
"wrongLanguage": "limbă greșită",
|
||||
"varietyNotStated": "(varietate nespecificată)",
|
||||
"inLanguage": "în"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/ru/gospel.json
Normal file
102
library/i18n/locales/ru/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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": "со ссылкой",
|
||||
"doxa0": "у всех что-то есть",
|
||||
"doxa1": "1–4 без ресурсов",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 достигнуты",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "Народы Doxa на этой карте",
|
||||
"notPlaced": "в списке, пока без координат",
|
||||
"outOfScope": "нет народов Doxa — не окрашено"
|
||||
},
|
||||
"tabs": {
|
||||
"groups": "Крупнейшие народы",
|
||||
"countries": "По странам",
|
||||
"regions": "По регионам",
|
||||
"noprayer": "Никто не молится",
|
||||
"hnr": "Слышат, но не читают"
|
||||
},
|
||||
"stat": {
|
||||
"language": "Язык",
|
||||
"population": "Численность",
|
||||
"countries": "Страны",
|
||||
"peopleGroups": "Народы",
|
||||
"noResource": "Ресурсов не найдено",
|
||||
"peopleAffected": "Затронутые люди",
|
||||
"haveScripture": "Есть Писание",
|
||||
"haveFilm": "Есть фильм",
|
||||
"haveAudio": "Есть аудио-Писание",
|
||||
"signLanguages": "Жестовые языки",
|
||||
"growingUnreached": "Растущие и недостигнутые",
|
||||
"deathsDay": "Оценка смертей в день"
|
||||
},
|
||||
"sheet": {
|
||||
"ownLanguage": "На их родном языке",
|
||||
"close": "Закрыть",
|
||||
"loading": "Загрузка ресурсов…",
|
||||
"pray": "Молитесь о",
|
||||
"everyResource": "Все ресурсы на",
|
||||
"alsoSpeak": "Также говорят на"
|
||||
},
|
||||
"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": "не проверено",
|
||||
"linkDead": "ссылка не работает",
|
||||
"wrongLanguage": "не тот язык",
|
||||
"varietyNotStated": "(вариант не указан)",
|
||||
"inLanguage": "на"
|
||||
}
|
||||
}
|
||||
102
library/i18n/locales/zh/gospel.json
Normal file
102
library/i18n/locales/zh/gospel.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"_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": "个有链接",
|
||||
"doxa0": "全部都有资源",
|
||||
"doxa1": "1–4 个一无所有",
|
||||
"doxa5": "5–14",
|
||||
"doxa15": "15–39",
|
||||
"doxa40": "40+",
|
||||
"cvd1": "1–4 个已触及",
|
||||
"cvd5": "5–14",
|
||||
"cvd15": "15–39",
|
||||
"cvd40": "40+",
|
||||
"onMap": "本地图上的 Doxa 族群",
|
||||
"notPlaced": "在名单上,尚未定位",
|
||||
"outOfScope": "没有 Doxa 族群 — 不着色"
|
||||
},
|
||||
"tabs": {
|
||||
"groups": "最大的族群",
|
||||
"countries": "按国家",
|
||||
"regions": "按地区",
|
||||
"noprayer": "无人代祷",
|
||||
"hnr": "能听,无文字"
|
||||
},
|
||||
"stat": {
|
||||
"language": "语言",
|
||||
"population": "人口",
|
||||
"countries": "国家",
|
||||
"peopleGroups": "族群",
|
||||
"noResource": "未找到资源",
|
||||
"peopleAffected": "受影响人数",
|
||||
"haveScripture": "有圣经",
|
||||
"haveFilm": "有影片",
|
||||
"haveAudio": "有有声圣经",
|
||||
"signLanguages": "手语",
|
||||
"growingUnreached": "增长中且未接触",
|
||||
"deathsDay": "每日估计死亡人数"
|
||||
},
|
||||
"sheet": {
|
||||
"ownLanguage": "以他们自己的语言",
|
||||
"close": "关闭",
|
||||
"loading": "正在加载资源…",
|
||||
"pray": "为其代祷:",
|
||||
"everyResource": "所有资源 —",
|
||||
"alsoSpeak": "同样使用"
|
||||
},
|
||||
"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": "未核实",
|
||||
"linkDead": "链接失效",
|
||||
"wrongLanguage": "语言不符",
|
||||
"varietyNotStated": "(未注明变体)",
|
||||
"inLanguage": "—"
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
// export { useEntitySearch } from './composables/useSearch.js'
|
||||
|
||||
// Colors — the old monolith shims (colorStrategies.js / colors.js /
|
||||
// prayerColors.js) are retired to ../_archive/ (see its README). Import the
|
||||
// prayerColors.js) are retired (removed from the library). Import the
|
||||
// real modules directly: './colors/_registry.js' (COLOR_MODES,
|
||||
// getColorStrategy, buildColorExpression), './colors/language-family.js',
|
||||
// './colors/engagement.js', './colors/adoption.js', './colors/prayer-progress.js'.
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ independently-built custom elements) into one application.
|
|||
- Icons are inline SVG only (no CDN) — an F-Droid/offline hard requirement.
|
||||
|
||||
**Sidebar contract, target filtering, sidebar modes, staging:**
|
||||
**[../documentation/architecture/app-wrappers.md](../documentation/architecture/app-wrappers.md)**.
|
||||
**[../documentation/architecture/app-wrappers.md](../../documentation/architecture/app-wrappers.md)**.
|
||||
|
|
|
|||
|
|
@ -497,6 +497,36 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
|
||||
}
|
||||
|
||||
/* ★ RIGHT-TO-LEFT (ar). THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them
|
||||
byte-identical. Everything above is written in PHYSICAL terms (left:0 rail, left:256px content,
|
||||
translateX(-…) slides), so an Arabic shell kept the rail on the left with LTR geometry (reported by
|
||||
studio for cell E, 2026-09-25). These are ADDITIVE overrides scoped by :dir(rtl), so an LTR consumer
|
||||
matches none of them and renders exactly as before. :dir() follows the dir attribute through the
|
||||
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
|
||||
translateX has no logical form, which is why this is a mirror block rather than logical properties. */
|
||||
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
|
||||
.vtl:dir(rtl) .vtl-main { left:0; right:256px; }
|
||||
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
|
||||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
|
||||
}
|
||||
`
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -955,4 +985,34 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:inline; }
|
||||
}
|
||||
|
||||
/* ★ RIGHT-TO-LEFT (ar). THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them
|
||||
byte-identical. Everything above is written in PHYSICAL terms (left:0 rail, left:256px content,
|
||||
translateX(-…) slides), so an Arabic shell kept the rail on the left with LTR geometry (reported by
|
||||
studio for cell E, 2026-09-25). These are ADDITIVE overrides scoped by :dir(rtl), so an LTR consumer
|
||||
matches none of them and renders exactly as before. :dir() follows the dir attribute through the
|
||||
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
|
||||
translateX has no logical form, which is why this is a mirror block rather than logical properties. */
|
||||
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
|
||||
.vtl:dir(rtl) .vtl-main { left:0; right:256px; }
|
||||
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
|
||||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
275
package.json
275
package.json
|
|
@ -13,31 +13,31 @@
|
|||
"clean": "node internal/clean.mjs",
|
||||
"build:bundle": "bun x vite build",
|
||||
"tree": "node internal/generate-tree.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs # writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"check:drop": "node internal/drop-portability-check.mjs",
|
||||
"check:parse": "node internal/inline-parse-check.mjs",
|
||||
"verify": "node library/scripts/research/verify-all.mjs",
|
||||
"verify:full": "node library/scripts/research/verify-all.mjs --full",
|
||||
"serve": "node internal/serve-strict.mjs # serves public/ the way PRODUCTION does: a DIRECTORY request returns 404, no index fallback. This was python3 -m http.server, which DID resolve a directory to index.html while the live static layer does not — so gates passed on URLs that 404 live (measured 2026-09-23; it cost a false \"publish failed\" reading and hid a bad URL in verify-shell-scroll for a whole flight). Loopback only, same as before. Override port/host with PORT/HOST.",
|
||||
"serve:shell": "python3 -m http.server 4174 --bind 127.0.0.1 --directory library/layouts/app-wrapper-material # LOOPBACK ONLY, same reason as `serve` above — a local preview server has no business answering on a public interface.",
|
||||
"cap:sync": "./node_modules/.bin/cap sync android # NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
|
||||
"serve": "node internal/serve-strict.mjs",
|
||||
"serve:shell": "python3 -m http.server 4174 --bind 127.0.0.1 --directory library/layouts/app-wrapper-material",
|
||||
"cap:sync": "./node_modules/.bin/cap sync android",
|
||||
"cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
|
||||
"check:tauri": "node internal/check-tauri-config.mjs",
|
||||
"tauri:build": "./node_modules/.bin/tauri build",
|
||||
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
|
||||
"apk:install": "adb install -r deployment/capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"phone": "bun run apk:debug && bun run apk:install",
|
||||
"shell": "node internal/install-shell.mjs --no-token # OPTIONAL, not part of build: overlays a layouts/ wrapper onto public/ for local use (the served root is the generated index.html); --no-token so the local credential is never copied into the served root",
|
||||
"shell": "node internal/install-shell.mjs --no-token",
|
||||
"posters": "cd library/scripts/posters && SPARSE=1 OFFLINE=1 VITE_API_BASE_URL=https://pray.doxa.life FORMAT=3x3 node generate.mjs",
|
||||
"posters:one": "cd library/scripts/posters && SPARSE=1 OFFLINE=1 VITE_API_BASE_URL=https://pray.doxa.life FORMAT=3x3 node generate.mjs # ONLY=<zoneKey> to build one",
|
||||
"posters:one": "cd library/scripts/posters && SPARSE=1 OFFLINE=1 VITE_API_BASE_URL=https://pray.doxa.life FORMAT=3x3 node generate.mjs",
|
||||
"posters:shoot": "cd library/scripts/posters && DSF=2 node shoot.mjs",
|
||||
"posters:gallery": "cd library/scripts/posters && node build-gallery.mjs && node build-hd-gallery.mjs",
|
||||
"posters:export": "cd library/scripts/posters && node export-public.mjs",
|
||||
"posters:previews": "cd library/scripts/posters && python3 make-previews.py",
|
||||
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs # the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",
|
||||
"packet-fixture": "node internal/generate-packet-fixture.mjs # regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
|
||||
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check # the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
|
||||
"packet:ci": "node internal/build-packet-fixtures.mjs # build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.",
|
||||
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs",
|
||||
"packet-fixture": "node internal/generate-packet-fixture.mjs",
|
||||
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
|
||||
"packet:ci": "node internal/build-packet-fixtures.mjs",
|
||||
"token": "node internal/write-token.mjs",
|
||||
"smoke": "node internal/smoke.mjs",
|
||||
"smoke:embeds": "node internal/embed-smoke.mjs",
|
||||
|
|
@ -46,19 +46,19 @@
|
|||
"smoke:share": "node internal/share-roundtrip-smoke.mjs",
|
||||
"probe:legend-columns": "node internal/legend-columns-probe.mjs",
|
||||
"sync:dropin": "node internal/sync-dropin.mjs",
|
||||
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune # destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
|
||||
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune",
|
||||
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:dxlife": "node internal/check-dxlife-untouched.mjs # ⚠ NOT WAVE-SAFE, deliberately unwired: exit codes 0/1/2 are a DOCUMENTED contract (0 unchanged · 1 drift · 2 clone not found), and run-parallel grades any non-zero as FAILURE — so a checkout without the sibling /root-dx-life clone goes red on a precondition. Changing the contract could break another consumer; hand-run it, or give it a recognised SKIP first.",
|
||||
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
|
||||
"check:dxlife": "node internal/check-dxlife-untouched.mjs",
|
||||
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
|
||||
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
|
||||
"check:facet-one-field": "node internal/check-facet-one-field.mjs # ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
|
||||
"check:facet-one-field": "node internal/check-facet-one-field.mjs",
|
||||
"check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test",
|
||||
"check:facet-changes-map": "node internal/check-facet-changes-map.mjs # a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
|
||||
"check:facet-changes-map:built": "sh -c 'BASE_URL=${BASE_URL:-http://127.0.0.1:4173} PROBE_PATH=${PROBE_PATH:-/doxa-maps/research-dashboard/research-dashboard/index.html} exec node internal/check-facet-changes-map.mjs' # the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",
|
||||
"check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:facet-changes-map": "node internal/check-facet-changes-map.mjs",
|
||||
"check:facet-changes-map:built": "sh -c 'BASE_URL=${BASE_URL:-http://127.0.0.1:4173} PROBE_PATH=${PROBE_PATH:-/doxa-maps/research-dashboard/research-dashboard/index.html} exec node internal/check-facet-changes-map.mjs'",
|
||||
"check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:card-deck": "node internal/check-card-deck.mjs",
|
||||
"check:poster-raster": "node internal/check-poster-raster.mjs",
|
||||
|
|
@ -66,72 +66,75 @@
|
|||
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
|
||||
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
|
||||
"check:poster-prints": "node internal/check-poster-prints.mjs",
|
||||
"check:provider-switch": "node internal/check-provider-switch.mjs # the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
|
||||
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs # the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs # a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:provider-switch": "node internal/check-provider-switch.mjs",
|
||||
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs",
|
||||
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
|
||||
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
|
||||
"check:share-policy": "node internal/check-share-policy.mjs",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs # every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs",
|
||||
"check:print-geometry": "node internal/check-print-geometry.mjs",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs # the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs",
|
||||
"check:packet-render": "node internal/check-packet-render.mjs",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
"verify:gospel-map": "node internal/verify-gospel-map.mjs # the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
|
||||
"verify:gospel-map": "node internal/verify-gospel-map.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"check:composite": "node internal/check-composite-freshness.mjs # a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
|
||||
"check:shots": "node internal/check-shot-freshness.mjs # a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
|
||||
"check:library-index": "node internal/check-library-index.mjs # the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
|
||||
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs # documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
|
||||
"capability-exemplars": "node internal/generate-capability-exemplars.mjs # regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
|
||||
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs # every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
|
||||
"check:catalog-identity": "node internal/check-catalog-identity.mjs # proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
|
||||
"check:composite": "node internal/check-composite-freshness.mjs",
|
||||
"check:shots": "node internal/check-shot-freshness.mjs",
|
||||
"check:library-index": "node internal/check-library-index.mjs",
|
||||
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs",
|
||||
"capability-exemplars": "node internal/generate-capability-exemplars.mjs",
|
||||
"check:a11y-button-names": "node internal/check-a11y-button-names.mjs",
|
||||
"check:catalog-identity": "node internal/check-catalog-identity.mjs",
|
||||
"check:catalog-currency": "node internal/check-catalog-currency.mjs",
|
||||
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs # REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
|
||||
"check:capability-citations": "node internal/check-capability-citations.mjs # a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
|
||||
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs # two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
|
||||
"check:shot-fidelity": "node internal/check-shot-fidelity.mjs # warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
|
||||
"shots": "node internal/readme-shots.mjs # regenerate documentation/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE — present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.",
|
||||
"library-index": "node internal/generate-library-index.mjs # regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
|
||||
"check:done-evidence": "node internal/check-done-evidence.mjs # a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",
|
||||
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs",
|
||||
"check:capability-citations": "node internal/check-capability-citations.mjs",
|
||||
"check:shot-distinctness": "node internal/check-shot-distinctness.mjs",
|
||||
"check:shot-fidelity": "node internal/check-shot-fidelity.mjs",
|
||||
"shots": "node internal/readme-shots.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"check:done-evidence": "node internal/check-done-evidence.mjs",
|
||||
"check:wp-embed": "php deployment/wordpress/tests/wp-embed-contract-test.php",
|
||||
"check:mobile": "bun run cap:sync && bun run cap:verify # deploy-2 CONTRACT check: needs NO JDK/SDK/device. cap sync is a file copy + plugin wiring, so it runs anywhere; cap:verify then asserts assets/public/index.html exists. Guards the SOFT FAILURE capacitor.config.json warns about: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an error.",
|
||||
"check:mobile": "bun run cap:sync && bun run cap:verify",
|
||||
"check:settled": "node internal/settled-tree-gate.mjs",
|
||||
"verify:5-caps": "node internal/verify-5-capabilities.mjs",
|
||||
"verify:gate1": "node internal/acceptance/big-picture-acceptance.mjs # the Gate-1 chain end-to-end against LIVE data: header emits research-filter -> composite passes props down -> the REAL cap-2 grid re-filters -> count reflected back up. Asserts the NEGATIVE (a filter that must return 0 does) and RESET. Was an ORPHAN — present in internal/acceptance/ but invoked by NO script, so it could never report anything.",
|
||||
"verify:interop": "node internal/acceptance/interop-harness.mjs # the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
|
||||
"verify:interop:self-test": "node internal/acceptance/interop-harness.mjs --self-test # TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
|
||||
"verify:interop:self-test:camera-freeze": "node internal/acceptance/interop-harness.mjs --self-test --self-test-mode=camera-freeze # ★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:descriptions": "node internal/check-profile-descriptions.mjs # every shipped profile must say what it is (reads the BUILT manifest)",
|
||||
"check:no-abs-paths": "node internal/check-no-absolute-paths.mjs # no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "node internal/check-no-internal-codename.mjs # the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
"check:dep-names": "node internal/check-dependency-names.mjs # every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
|
||||
"check:embed-targets": "node internal/check-embed-targets-exist.mjs # every embed.iframe src in manifest.json must resolve to a file that EXISTS. Those snippets ARE deployment-method 1 (iframe) — a partner pastes them verbatim — and NOTHING else asserts their targets are still emitted, because every other gate walks only files that exist and is blind to ABSENCE. `the `check:embed-targets:self-test` script` poisons a copy of the manifest and asserts the gate NOTICES: a gate that cannot fail is worse than no gate.",
|
||||
"verify:gate1": "node internal/acceptance/big-picture-acceptance.mjs",
|
||||
"verify:interop": "node internal/acceptance/interop-harness.mjs",
|
||||
"verify:interop:self-test": "node internal/acceptance/interop-harness.mjs --self-test",
|
||||
"verify:interop:self-test:camera-freeze": "node internal/acceptance/interop-harness.mjs --self-test --self-test-mode=camera-freeze",
|
||||
"check:descriptions": "node internal/check-profile-descriptions.mjs",
|
||||
"check:no-abs-paths": "node internal/check-no-absolute-paths.mjs",
|
||||
"check:no-codename": "node internal/check-no-internal-codename.mjs",
|
||||
"check:dep-names": "node internal/check-dependency-names.mjs",
|
||||
"check:embed-targets": "node internal/check-embed-targets-exist.mjs",
|
||||
"check:composite-srcs": "node internal/check-composite-srcs.mjs",
|
||||
"check:nested-token": "node internal/check-nested-token-delivery.mjs",
|
||||
"check:nav-reachable": "node internal/check-nav-reachable-mobile.mjs # a cap-5 shell must have ONE on-screen control that opens its tab rail at phone width (needs `bun run serve` on 4173; browser gate, not in build:unlocked)",
|
||||
"check:fits-screen": "node internal/check-fits-the-screen.mjs # THE FITNESS-FOR-USE GATE (LL-the-user-found-three-bugs-my-gates-could-not-see): a green STATE suite (mounts/fetches/counts/paints) said nothing about whether the product is USABLE, and the Driver found five real defects in one sitting that no gate saw. Asserts 5 clauses on the built dashboard-plus-grid composite + research-dashboard leaf, real browser, live data: (A) the dashboard band fits ONE 1440x900 viewport and is shorter than its own tree-legend's scrollHeight; (B) the people-groups grid is still reachable by scrolling underneath it (a fix for A must not re-break this — AP-fixing-reachability-by-making-the-thing-taller); (C) a wheel over the map changes ZOOM and does not scroll the pane, a wheel below it scrolls and does not zoom — instrumented on the DISPATCHED EVENT (composedPath), not geometry, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry; (D) the grid's live count ([data-table-count], the composite's documented single source of truth — show-total=false on the grid child is deliberate) is visible and CHANGES when a facet narrows it; (E) a 1440/1280/768/390 responsive sweep has no horizontal document overflow and no element clipped to zero height (.rd-td/.rd-th ellipsis excluded — expected). Spawns its own serve-strict on PORT (default 4391); SKIPs (exit 0) if the tree is not settled at START OR re-settled at END, or pray.doxa.life is unreachable — never reports a defect from a torn tree. Also best-effort probes any cap-5 shell the manifest declares (type nested-mfe-spa) by CONTENT-MATCHING a tab, never by hardcoded structure, so it survives the shell's own in-flight restructure without depending on it. Needs a built tree (`bun run build`). run `check:fits-screen:self-test` for the NEGATIVE test.",
|
||||
"check:fits-screen:self-test": "node internal/check-fits-the-screen.mjs --self-test # FIVE regression modes, one per clause, each required to fire ITS OWN [A]/[B]/[C]/[D]/[E]-named failure: run clause a via THIS entry and b-e via `check:fits-screen:self-test:<b|c|d|e>`. a (default — forces the dashboard section past the viewport, the AP-fixing-reachability shape), b (n/a here — B is proven by A's own poison already trapping the grid below the fold; kept as a mode for a future independent poison), c (freezes map.scrollZoom enabled, the BUG-MAP-EATS-THE-WHEEL regression), d (blanks the live count element), e (forces the shell wider than the viewport at 1440). A gate that has only ever printed PASS is unproven.",
|
||||
"check:fits-screen:self-test:b": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=b # ★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:c": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=c # ★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:d": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=d # ★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:e": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=e # ★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"verify:browser-all": "node internal/verify-browser-all.mjs # runs EVERY browser gate in the GATES array over the built tree in one command (needs `bun run serve` on 4173 + MAPBOX_TOKEN). Deliberately does NOT state a count: GATES lives in the .mjs and grows, so a number here rots on the next append — it said \"ALL 7\" while the array held 9. The script prints its own GATES.length at runtime; trust that. Every gate listed was RUN before being listed. The `verify:browser-all:plugin-gallery` mode is separate because it needs the vite dev server.",
|
||||
"check:shot-fonts": "node internal/check-shot-fonts.mjs # can THIS machine render the emoji the UI uses? Run BEFORE `bun run shots` — no font = tofu boxes in every committed PNG, which every other shot gate passes. Deliberately NOT in build:unlocked: it reports an ENVIRONMENT condition (this box has no emoji font), and a gate that always fails in the build makes the build useless.",
|
||||
"check:nav-reachable": "node internal/check-nav-reachable-mobile.mjs",
|
||||
"check:fits-screen": "node internal/check-fits-the-screen.mjs",
|
||||
"check:fits-screen:self-test": "node internal/check-fits-the-screen.mjs --self-test",
|
||||
"check:fits-screen:self-test:b": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=b",
|
||||
"check:fits-screen:self-test:c": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=c",
|
||||
"check:fits-screen:self-test:d": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=d",
|
||||
"check:fits-screen:self-test:e": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=e",
|
||||
"verify:browser-all": "node internal/verify-browser-all.mjs",
|
||||
"check:shot-fonts": "node internal/check-shot-fonts.mjs",
|
||||
"smoke:share-all": "sh -c 'B=${BASE_URL:-http://127.0.0.1:4173}; exec node internal/share-roundtrip-smoke.mjs --url \"$B/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/all-people-groups/all-people-groups/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/cities-internet/connectivity-map/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/country-posters/country-posters-mfe/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/demo-map/demo-map/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/doxa-research-map/research-map/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/doxa-simple-map/doxa-simple-map/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/gospel-resources/resources-map/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/research-dashboard/research-dashboard/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html?tk=$MAPBOX_TOKEN\" --url \"$B/doxa-maps/my-upg-100-list/upg-100-mfe/index.html?tk=$MAPBOX_TOKEN\" --browser chromium'",
|
||||
"smoke:legend-info": "sh -c 'B=${BASE_URL:-http://127.0.0.1:4173}; exec node internal/check-legend-info-localized.mjs \"$B/doxa-maps/my-upg-100-list/upg-100-list/index.html\" es,ar,zh'",
|
||||
"smoke:aria-labels": "sh -c 'B=${BASE_URL:-http://127.0.0.1:4173}; exec node internal/check-aria-labels-localized.mjs \"$B/doxa-maps/my-upg-100-list/upg-100-list/index.html\" es,ar,zh'",
|
||||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs # every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
|
||||
"check:url-resolver": "node internal/check-url-resolver.mjs # cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
|
||||
"smoke:studio": "node internal/smoke-studio.mjs # 5s DEV-surface check: / mounts the studio, tabs>0, 0 4xx (exit 2 = dev server down)",
|
||||
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs # every DISCOVERED (non-gated) bundle must be in the built manifest",
|
||||
"check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
|
||||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:url-resolver": "node internal/check-url-resolver.mjs",
|
||||
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
|
||||
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
|
||||
"check:map-unmount-mid-init": "node internal/check-map-unmount-mid-init.mjs",
|
||||
"smoke:studio": "node internal/smoke-studio.mjs",
|
||||
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs",
|
||||
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
|
||||
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
|
||||
"check:verification-tags": "node internal/check-verification-tags.mjs",
|
||||
"check:fixed-anchoring": "node internal/check-fixed-anchoring.mjs",
|
||||
"check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs # a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
|
||||
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test # reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
|
||||
"check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs",
|
||||
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",
|
||||
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
|
||||
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
|
||||
"check:shot-fidelity:strict": "node internal/check-shot-fidelity.mjs --strict",
|
||||
|
|
@ -147,45 +150,139 @@
|
|||
"check:synced-css": "node internal/check-synced-css-copies.mjs",
|
||||
"check:composite-reg": "node internal/check-composite-registration.mjs",
|
||||
"check:filter-width": "node internal/check-filter-width-stable.mjs",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs # the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed": "node internal/check-research-gallery-embed.mjs",
|
||||
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
|
||||
"check:gallery-embed:iframe": "node internal/check-research-gallery-embed.mjs --mode iframe",
|
||||
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs # the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:spa-tab-order": "node internal/check-spa-tab-order.mjs",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:backlog-refs": "node internal/check-backlog-refs.mjs # a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
|
||||
"check:backlog-refs": "node internal/check-backlog-refs.mjs",
|
||||
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
|
||||
"check:snapshot-guard": "node internal/check-snapshot-guard.mjs # every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:snapshot-guard": "node internal/check-snapshot-guard.mjs",
|
||||
"check:snapshot-guard:poison": "node internal/check-snapshot-guard.mjs --poison",
|
||||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs # the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
|
||||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs # the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs # a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs # a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
|
||||
"check:stale-sweep-claim": "node internal/check-no-stale-sweep-claim.mjs",
|
||||
"check:surfaces-read-the-tier": "node internal/check-surfaces-read-the-tier.mjs",
|
||||
"check:surfaces-read-the-tier:poison": "node internal/check-surfaces-read-the-tier.mjs --poison",
|
||||
"check:link-rot": "node internal/check-link-rot-sample.mjs # re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
|
||||
"check:embed-basemap": "node internal/check-embed-basemap-paints.mjs # the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
|
||||
"check:link-rot": "node internal/check-link-rot-sample.mjs",
|
||||
"check:embed-basemap": "node internal/check-embed-basemap-paints.mjs",
|
||||
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
|
||||
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
"serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public",
|
||||
"check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
|
||||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source check:backlog-refs check:composite-embed-knobs",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles",
|
||||
"check:composite-embed-knobs": "node internal/check-composite-embed-knobs.mjs # a composite must pass the HOST's tk and provider down to the map it nests, proven on a genuinely foreign origin (tree served on one port, partner page on another). Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks where there is no leaf - the partner page the embed snippet is written for, and the case nothing else tested. Keyless maplibre asserts ZERO requests to api.mapbox.com, because 'it rendered' would pass on a build that quietly fell back with a baked key.",
|
||||
"check:composite-embed-knobs": "node internal/check-composite-embed-knobs.mjs",
|
||||
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs # runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs # clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs # the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
|
||||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs # with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
"check:upg-map-data": "node internal/check-upg-map-data-painted.mjs # the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
|
||||
"check:published": "node internal/check-published-matches-build.mjs # what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
|
||||
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs # a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
|
||||
"check:tree-weight": "node internal/check-published-tree-weight.mjs # the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs # a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:embed-base": "node internal/check-embed-base-resolution.mjs # the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish."
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs",
|
||||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:upg-map-data": "node internal/check-upg-map-data-painted.mjs",
|
||||
"check:published": "node internal/check-published-matches-build.mjs",
|
||||
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
|
||||
"check:tree-weight": "node internal/check-published-tree-weight.mjs",
|
||||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
|
||||
"check:embed-base": "node internal/check-embed-base-resolution.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
|
||||
"serve": "serves public/ the way PRODUCTION does: a DIRECTORY request returns 404, no index fallback. This was python3 -m http.server, which DID resolve a directory to index.html while the live static layer does not — so gates passed on URLs that 404 live (measured 2026-09-23; it cost a false \"publish failed\" reading and hid a bad URL in verify-shell-scroll for a whole flight). Loopback only, same as before. Override port/host with PORT/HOST.",
|
||||
"serve:shell": "LOOPBACK ONLY, same reason as `serve` above — a local preview server has no business answering on a public interface.",
|
||||
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
|
||||
"shell": "OPTIONAL, not part of build: overlays a layouts/ wrapper onto public/ for local use (the served root is the generated index.html); --no-token so the local credential is never copied into the served root",
|
||||
"posters:one": "ONLY=<zoneKey> to build one",
|
||||
"packets": "the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",
|
||||
"packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
|
||||
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
|
||||
"packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.",
|
||||
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
|
||||
"check:dxlife": "⚠ NOT WAVE-SAFE, deliberately unwired: exit codes 0/1/2 are a DOCUMENTED contract (0 unchanged · 1 drift · 2 clone not found), and run-parallel grades any non-zero as FAILURE — so a checkout without the sibling /root-dx-life clone goes red on a precondition. Changing the contract could break another consumer; hand-run it, or give it a recognised SKIP first.",
|
||||
"check:facet-cascade": "a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
|
||||
"check:facet-one-field": "ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
|
||||
"check:facet-changes-map": "a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
|
||||
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",
|
||||
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
|
||||
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
"verify:gospel-map": "the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
|
||||
"check:composite": "a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
|
||||
"check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
|
||||
"check:library-index": "the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
|
||||
"check:quoted-scripts": "documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
|
||||
"capability-exemplars": "regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
|
||||
"check:a11y-button-names": "every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
|
||||
"check:catalog-identity": "proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
|
||||
"check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
|
||||
"check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
|
||||
"check:shot-distinctness": "two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
|
||||
"check:shot-fidelity": "warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
|
||||
"shots": "regenerate documentation/readme-images/*.png (fetches its own Mapbox token SERVER-SIDE, so the browser CORS block does not apply). Was UNREACHABLE — present but invoked by no script, which is how the 12 PNGs silently rotted a day behind the UI.",
|
||||
"library-index": "regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
|
||||
"check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",
|
||||
"check:mobile": "deploy-2 CONTRACT check: needs NO JDK/SDK/device. cap sync is a file copy + plugin wiring, so it runs anywhere; cap:verify then asserts assets/public/index.html exists. Guards the SOFT FAILURE capacitor.config.json warns about: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an error.",
|
||||
"verify:gate1": "the Gate-1 chain end-to-end against LIVE data: header emits research-filter -> composite passes props down -> the REAL cap-2 grid re-filters -> count reflected back up. Asserts the NEGATIVE (a filter that must return 0 does) and RESET. Was an ORPHAN — present in internal/acceptance/ but invoked by NO script, so it could never report anything.",
|
||||
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
|
||||
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
|
||||
"verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
|
||||
"check:no-abs-paths": "no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
|
||||
"check:embed-targets": "every embed.iframe src in manifest.json must resolve to a file that EXISTS. Those snippets ARE deployment-method 1 (iframe) — a partner pastes them verbatim — and NOTHING else asserts their targets are still emitted, because every other gate walks only files that exist and is blind to ABSENCE. `the `check:embed-targets:self-test` script` poisons a copy of the manifest and asserts the gate NOTICES: a gate that cannot fail is worse than no gate.",
|
||||
"check:nav-reachable": "a cap-5 shell must have ONE on-screen control that opens its tab rail at phone width (needs `bun run serve` on 4173; browser gate, not in build:unlocked)",
|
||||
"check:fits-screen": "THE FITNESS-FOR-USE GATE (LL-the-user-found-three-bugs-my-gates-could-not-see): a green STATE suite (mounts/fetches/counts/paints) said nothing about whether the product is USABLE, and the Driver found five real defects in one sitting that no gate saw. Asserts 5 clauses on the built dashboard-plus-grid composite + research-dashboard leaf, real browser, live data: (A) the dashboard band fits ONE 1440x900 viewport and is shorter than its own tree-legend's scrollHeight; (B) the people-groups grid is still reachable by scrolling underneath it (a fix for A must not re-break this — AP-fixing-reachability-by-making-the-thing-taller); (C) a wheel over the map changes ZOOM and does not scroll the pane, a wheel below it scrolls and does not zoom — instrumented on the DISPATCHED EVENT (composedPath), not geometry, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry; (D) the grid's live count ([data-table-count], the composite's documented single source of truth — show-total=false on the grid child is deliberate) is visible and CHANGES when a facet narrows it; (E) a 1440/1280/768/390 responsive sweep has no horizontal document overflow and no element clipped to zero height (.rd-td/.rd-th ellipsis excluded — expected). Spawns its own serve-strict on PORT (default 4391); SKIPs (exit 0) if the tree is not settled at START OR re-settled at END, or pray.doxa.life is unreachable — never reports a defect from a torn tree. Also best-effort probes any cap-5 shell the manifest declares (type nested-mfe-spa) by CONTENT-MATCHING a tab, never by hardcoded structure, so it survives the shell's own in-flight restructure without depending on it. Needs a built tree (`bun run build`). run `check:fits-screen:self-test` for the NEGATIVE test.",
|
||||
"check:fits-screen:self-test": "FIVE regression modes, one per clause, each required to fire ITS OWN [A]/[B]/[C]/[D]/[E]-named failure: run clause a via THIS entry and b-e via `check:fits-screen:self-test:<b|c|d|e>`. a (default — forces the dashboard section past the viewport, the AP-fixing-reachability shape), b (n/a here — B is proven by A's own poison already trapping the grid below the fold; kept as a mode for a future independent poison), c (freezes map.scrollZoom enabled, the BUG-MAP-EATS-THE-WHEEL regression), d (blanks the live count element), e (forces the shell wider than the viewport at 1440). A gate that has only ever printed PASS is unproven.",
|
||||
"check:fits-screen:self-test:b": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:c": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:d": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:e": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"verify:browser-all": "runs EVERY browser gate in the GATES array over the built tree in one command (needs `bun run serve` on 4173 + MAPBOX_TOKEN). Deliberately does NOT state a count: GATES lives in the .mjs and grows, so a number here rots on the next append — it said \"ALL 7\" while the array held 9. The script prints its own GATES.length at runtime; trust that. Every gate listed was RUN before being listed. The `verify:browser-all:plugin-gallery` mode is separate because it needs the vite dev server.",
|
||||
"check:shot-fonts": "can THIS machine render the emoji the UI uses? Run BEFORE `bun run shots` — no font = tofu boxes in every committed PNG, which every other shot gate passes. Deliberately NOT in build:unlocked: it reports an ENVIRONMENT condition (this box has no emoji font), and a gate that always fails in the build makes the build useless.",
|
||||
"check:sfc-parse": "every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
|
||||
"check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
|
||||
"smoke:studio": "5s DEV-surface check: / mounts the studio, tabs>0, 0 4xx (exit 2 = dev server down)",
|
||||
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
|
||||
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
|
||||
"check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
|
||||
"check:scrim-viewport:self-test": "reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
|
||||
"check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
|
||||
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
|
||||
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
|
||||
"check:embed-basemap": "the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
|
||||
"serve:permissive": "LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
"check:legend-source": "the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"check:composite-embed-knobs": "a composite must pass the HOST's tk and provider down to the map it nests, proven on a genuinely foreign origin (tree served on one port, partner page on another). Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks where there is no leaf - the partner page the embed snippet is written for, and the case nothing else tested. Keyless maplibre asserts ZERO requests to api.mapbox.com, because 'it rendered' would pass on a build that quietly fell back with a baked key.",
|
||||
"check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
|
||||
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
"check:upg-map-data": "the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
|
||||
"check:published": "what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
|
||||
"check:shared-reached": "a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
|
||||
"check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
|
||||
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
|
||||
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -22,4 +22,4 @@ another map. Two surfaces sit side by side:
|
|||
**Writing a strategy?** The full contract (required `export default` shape, checklist,
|
||||
profile-local overrides) is in
|
||||
**[../../../../documentation/architecture/color-strategies.md](../../../../documentation/architecture/color-strategies.md)**.
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../contributing/COLORS.md).
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../documentation/contributing/COLORS.md).
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
# Embedding the Research Gallery
|
||||
|
||||

|
||||
|
||||
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
|
||||
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
|
||||
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
|
||||
`https://YOUR-HOST/doxa-maps`.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | **Script embed** | **iframe embed** |
|
||||
|---|---|---|
|
||||
| **Paste** | two lines: `<script>` + `<doxa-maps-spa>` | one `<iframe>` |
|
||||
| **Sizing** | a block in your layout, `85vh` by default | whatever size you give the frame |
|
||||
| **Language** | follows your page (`<html lang>`), or `language="…"` | `?lang=…` on the `src` |
|
||||
| **Open on a map** | `tab="upg-100"` | `?tab=upg-100` |
|
||||
| **Keyless maps** | `provider="maplibre"` | `?provider=maplibre` |
|
||||
| **Your server must send** | CORS on the whole tree | CORS + `frame-ancestors` on the leaf page |
|
||||
|
||||
**Seven production maps** sit in the rail: Big Picture, Simple Research and Regions (Dashboards);
|
||||
UPG-100 List and Prayer Progress (Prayer Mobilization); Cities with Internet and Gospel Resources
|
||||
(Gospel Research). An Experimental folder collects everything else in the tree.
|
||||
|
||||
## Script embed (recommended)
|
||||
|
||||
```html
|
||||
|
|
@ -37,7 +54,7 @@ Add `?tab=<id>` to the `src` to open a specific map.
|
|||
| Attribute | Default | What it does |
|
||||
|---|---|---|
|
||||
| `tab` | device default (Big Picture on desktop, Simple Research on phones) | Tab id to open first. Changing it later switches tabs. `?tab=` on the page URL works too. |
|
||||
| `language` | parent page / saved choice | UI language pushed to every tab (`en`, `es`, `fr`, …). |
|
||||
| `language` | your page's language | UI language for the gallery and every tab. Without it the gallery follows your page: `?lang=` in the URL, then `<html lang>` (watched live, so a page that switches language switches the gallery), then English. The visitor's browser language is never used on its own. 11 languages ship: `ar de en es fr hi it pt ro ru zh`. |
|
||||
| `api-base-url` | manifest `runtime.apiBaseUrl` | Data API. Only applied when the page has not already set `window.MAP_APP_API_URL`. |
|
||||
| `provider` | each map's own default (Mapbox) | Map engine for every nested map: `maplibre` needs no token and makes no Mapbox requests; `mapbox`. Forwarded to each map and, when the page has not set `window.MAP_PROVIDER`, set there too so maps nested inside composites follow. In the iframe embed use `?provider=maplibre`. |
|
||||
| `tk` | manifest `runtime.token` | Mapbox public token. Only fills `window.MAP_TOKEN` when the page has not set one. |
|
||||
|
|
@ -65,7 +82,7 @@ The gallery is tested to leave its host page as it found it: it does not scroll
|
|||
your URL or `<title>`, or inherit your body typography (a blog's `font: 20px/3` stays outside the
|
||||
box). It does add `@font-face` rules and the map engine's stylesheet to your `<head>`; fonts have to
|
||||
be declared at document level to reach a shadow root, and the engine styles only target map classes.
|
||||
Right-to-left pages keep their direction. `bun run check:gallery-embed` enforces all of this.
|
||||
Direction follows the gallery's language: in Arabic the whole shell mirrors, with the tab rail on the right. `bun run check:gallery-embed` enforces all of this.
|
||||
|
||||
## What the server must send
|
||||
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@
|
|||
the direct-element mount and the heavy-tab teardown machinery live in the default slot below,
|
||||
driven by activeIndex. -->
|
||||
<VerticalTabsLayout
|
||||
:dir="uiDir"
|
||||
:lang="uiLocale"
|
||||
:labels="layoutLabels"
|
||||
:title="heading"
|
||||
nav-label="Research maps"
|
||||
:nav-label="t('navLabel')"
|
||||
:header="showHeader"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
|
|
@ -77,7 +80,7 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<template #meta>{{ tabs.length }} maps</template>
|
||||
<template #meta>{{ t('meta', { n: tabs.length }) }}</template>
|
||||
|
||||
<!-- #toolbar: the i18n locale switcher (3-dots/flag). GATED by showLocaleSwitcher — the cap-5
|
||||
DX-Maps SPA has NO standalone language dropdown by default; it INHERITS its language from the
|
||||
|
|
@ -127,18 +130,18 @@
|
|||
|
||||
<template v-if="!t.heavy || i === activeIndex">
|
||||
<div class="dms-slot" :ref="el => setSlot(i, el)"></div>
|
||||
<div v-if="t.state === 'loading'" class="dms-hint">Loading {{ t.label }}…</div>
|
||||
<div v-if="t.state === 'loading'" class="dms-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
|
||||
<div v-else-if="t.state === 'error'" class="dms-hint err">
|
||||
Could not load {{ t.label }}.<br><small>{{ t.error }}</small>
|
||||
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
|
||||
</div>
|
||||
<!-- ★ THE FOURTH STATE, and the one that used to render NOTHING. A tab whose scriptUrl the
|
||||
manifest never supplied sits in state 'idle' forever: not loading, not errored, just an
|
||||
empty slot — the grey pane the Driver reported four times. Covering it here means a
|
||||
missing manifest now READS as a failure instead of looking like a blank app. -->
|
||||
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="dms-hint err">
|
||||
Could not load {{ t.label }}.<br>
|
||||
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
|
||||
<small>{{ manifestError }}</small><br>
|
||||
<small>This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.</small>
|
||||
<small>{{ tr('manifestHint') }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
|
@ -155,6 +158,25 @@
|
|||
import { defineComponent, inject } from 'vue'
|
||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
import { translate, LOCALES } from './src/strings.js'
|
||||
|
||||
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
|
||||
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
|
||||
// NOT navigator.language: the gallery used to fall back to the BROWSER's language and force it onto
|
||||
// every child through profile-config.locale — measured 2026-09-25, a Spanish page (<html lang="es">)
|
||||
// viewed in a German browser rendered its maps in German and its own rail in English.
|
||||
function detectHostLocale() {
|
||||
try {
|
||||
const q = new URLSearchParams(location.search)
|
||||
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
|
||||
if (LOCALES.includes(fromUrl)) return fromUrl
|
||||
} catch { /* no window */ }
|
||||
try {
|
||||
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
|
||||
if (LOCALES.includes(fromHtml)) return fromHtml
|
||||
} catch { /* no document */ }
|
||||
return ''
|
||||
}
|
||||
|
||||
// The Driver's section/folder nav model, ported from app-wrapper-material/nav.js (the verified
|
||||
// source of the exact sections the Driver wants). Each entry is a GROUP for VerticalTabsLayout; each
|
||||
|
|
@ -180,7 +202,7 @@ const NAV_MODEL = [
|
|||
// dev-time gate below); the fix is that Dashboards now owns TWO DISTINCT rows of its own — Big
|
||||
// Picture never appears in Prayer Mobilization again — so it is not a one-row section that begs
|
||||
// to be folded away.
|
||||
{ key: 'dashboards', label: 'Dashboards', icon: 'chart',
|
||||
{ key: 'dashboards', label: 'Dashboards', i18n: 'sec.dashboards', icon: 'chart',
|
||||
tabs: [
|
||||
// 1. Big Picture — desktop default. Present on EVERY width (the device rule is "same tab
|
||||
// order everywhere"), but DISABLED on mobile rather than hidden — see isBigPictureDisabled /
|
||||
|
|
@ -215,7 +237,7 @@ const NAV_MODEL = [
|
|||
// "if you're trying to mobilize prayer, UPG-100 list probably comes first because it's a NEW thing
|
||||
// to people — you want them introduced to it first" — hand the newcomer the concrete new artifact,
|
||||
// THEN ask them to pray it.
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', icon: 'heart',
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', i18n: 'sec.prayer', icon: 'heart',
|
||||
tabs: [
|
||||
{ id: 'upg-100', icon: 'list' }, // 1. UPG-100 List — the NEW thing, introduced first
|
||||
{ id: 'prayer-map', icon: 'embed' }, // 2. Prayer Progress — now pray the list
|
||||
|
|
@ -226,7 +248,7 @@ const NAV_MODEL = [
|
|||
// 2 (Simple Research) — so the standalone map is left OUT of every curated section and falls
|
||||
// through to Experimental automatically (see curatedBundles/experimentalTabsFromManifest); that is
|
||||
// correct, not an omission.
|
||||
{ key: 'gospel-research', label: 'Gospel Research', icon: 'search',
|
||||
{ key: 'gospel-research', label: 'Gospel Research', i18n: 'sec.gospel', icon: 'search',
|
||||
tabs: [
|
||||
{ id: 'cities-internet', icon: 'wifi' }, // renamed label: "Cities with Internet"
|
||||
{ id: 'gospel-resources', icon: 'book' },
|
||||
|
|
@ -243,7 +265,7 @@ const NAV_MODEL = [
|
|||
// Driver set for the data bundler: handle every plugin generically, or the next author gets
|
||||
// nothing. The named sections above stay curated (they encode the teaching order); Experimental
|
||||
// is the automatic catch-all so nothing can go missing.
|
||||
{ key: 'experimental', label: 'Experimental', icon: 'flask', folder: true, open: false, tabs: [] },
|
||||
{ key: 'experimental', label: 'Experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false, tabs: [] },
|
||||
]
|
||||
|
||||
// Bundles that are NOT candidates for auto-discovery into Experimental, with the reason each is
|
||||
|
|
@ -786,6 +808,9 @@ export default defineComponent({
|
|||
runtime: {},
|
||||
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
||||
locale: loadLocale(),
|
||||
// The host page's own language (detectHostLocale), kept live by a MutationObserver on <html lang>
|
||||
// — a host app that switches language at runtime (Nuxt i18n does) re-drives the whole gallery.
|
||||
hostLang: detectHostLocale(),
|
||||
localeOpen: false,
|
||||
switcherLangs: SWITCHER_LANGUAGES,
|
||||
// Curated tabs start from TAB_DEFS and are COMPLETED from the manifest (tag + scriptUrl) as
|
||||
|
|
@ -872,8 +897,8 @@ export default defineComponent({
|
|||
// of a mount.
|
||||
const disabled = this.isBigPictureDisabled(t.id)
|
||||
return {
|
||||
key: t.id, label: t.label, sub: t.sub, icon: r.icon,
|
||||
badge: disabled ? 'desktop only' : r.badge, disabled
|
||||
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: r.icon,
|
||||
badge: disabled ? this.tr('desktopOnly') : r.badge, disabled
|
||||
}
|
||||
}),
|
||||
seen)
|
||||
|
|
@ -886,10 +911,10 @@ export default defineComponent({
|
|||
// routing it through `seen` means even a mis-declared bundle can only ever paint one row.
|
||||
rows = rows.concat(dedupeRows(
|
||||
this.tabs.filter(t => t.discovered)
|
||||
.map(t => ({ key: t.id, label: t.label, sub: t.sub, icon: 'flask', badge: 'lab' })),
|
||||
.map(t => ({ key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: 'flask', badge: this.tr('lab') })),
|
||||
seen))
|
||||
}
|
||||
return { key: g.key, label: g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
|
||||
return { key: g.key, label: g.i18n ? this.tr(g.i18n) : g.label, icon: g.icon, folder: !!g.folder, open: g.open, rows }
|
||||
})
|
||||
.filter(g => g.rows.length)
|
||||
},
|
||||
|
|
@ -904,9 +929,28 @@ export default defineComponent({
|
|||
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
|
||||
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
|
||||
// Every tab's element receives THIS via profile-config.locale, so the SPA honors one language prop.
|
||||
// 1. `language` prop / profile-config.language|locale|lang — what the host SAYS.
|
||||
// 2. the gallery's own switcher choice — only while that switcher is actually offered.
|
||||
// 3. the host page's language (?lang= / <html lang>, see detectHostLocale).
|
||||
// 4. English.
|
||||
effectiveLocale() {
|
||||
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
|
||||
return (fromProp || this.locale || '').split('-')[0].toLowerCase()
|
||||
const fromSwitcher = this.showLocaleSwitcher ? this.locale : ''
|
||||
return (fromProp || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
|
||||
},
|
||||
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
|
||||
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
|
||||
// Direction FOLLOWS THE GALLERY'S LANGUAGE, set explicitly on the layout root (a fallthrough attr
|
||||
// on VerticalTabsLayout's .vtl): Arabic lays the shell out right-to-left (the layout mirrors its
|
||||
// rail under :dir(rtl)), every other shipped locale left-to-right — including an English gallery
|
||||
// placed on some other RTL page, whose Latin rail must not be mirrored.
|
||||
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
|
||||
// The shared layout's own controls (its visible "Collapse" button and four aria-labels) — English
|
||||
// defaults inside VerticalTabsLayout, translated here through its `labels` contract.
|
||||
layoutLabels() {
|
||||
const out = {}
|
||||
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse']) out[k] = this.tr(k)
|
||||
return out
|
||||
},
|
||||
// The engine a HOST asked for ('' = none; each map keeps its own ladder).
|
||||
effectiveProvider() {
|
||||
|
|
@ -924,6 +968,7 @@ export default defineComponent({
|
|||
// then drop the row out from under the active highlight — the tab would be mounted but
|
||||
// unreachable, which reads as a broken app rather than a flicker.
|
||||
this.watchWidth()
|
||||
this.watchHostLang()
|
||||
// Open the first tab immediately (its map is the one load the SPA pays on open). The shared
|
||||
// VerticalTabsLayout owns rail open/close + narrow-screen collapse now.
|
||||
this.select(this.initialIndex())
|
||||
|
|
@ -937,6 +982,7 @@ export default defineComponent({
|
|||
// will (a cap-5 SPA is mounted and unmounted by shells, tabs and the studio), and a live listener
|
||||
// holding a reference to a detached component leaks it for the page's lifetime.
|
||||
beforeUnmount() {
|
||||
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
|
||||
if (!this._mql || !this._onMedia) return
|
||||
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
|
||||
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
|
||||
|
|
@ -964,6 +1010,18 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
// The gallery's own words in uiLocale (src/strings.js). `t` in the template is shadowed by the
|
||||
// v-for tab variable inside the panes, so those use `tr`; both are the same function.
|
||||
tr(key, params) { return translate(this.uiLocale, key, params) },
|
||||
t(key, params) { return translate(this.uiLocale, key, params) },
|
||||
// A curated tab's label/sub come from the string table; a DISCOVERED (Experimental) tab has only
|
||||
// its manifest title, which stays as published. Its English manifest description is replaced by
|
||||
// the translated generic subtitle outside English, so no English prose leaks into the rail.
|
||||
tabLabel(t) { return t.discovered ? t.label : translate(this.uiLocale, 'tab.' + t.id) },
|
||||
tabSub(t) {
|
||||
if (!t.discovered) return translate(this.uiLocale, 'sub.' + t.id)
|
||||
return this.uiLocale === 'en' ? t.sub : this.tr('experimentalSub')
|
||||
},
|
||||
// ── VIEWPORT WATCH (the device rule: same rail, different default + one disabled row) ────────
|
||||
// Mirrors the SAME (max-width:760px) query VerticalTabsLayout uses. See the `narrow` note in
|
||||
// data() for why the shell must watch it independently.
|
||||
|
|
@ -971,6 +1029,13 @@ export default defineComponent({
|
|||
// FAIL-SOFT: if matchMedia is unavailable (an old webview, a non-browser render) `narrow` stays
|
||||
// false and the SPA behaves exactly as it did before this change — desktop rail, Big Picture
|
||||
// first. A viewport feature must never be able to take the app down.
|
||||
// Follow the host page's <html lang> live. Fail-soft: without MutationObserver the language read
|
||||
// at mount stands.
|
||||
watchHostLang() {
|
||||
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
|
||||
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
|
||||
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
||||
},
|
||||
watchWidth() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return
|
||||
this._mql = window.matchMedia('(max-width: 760px)')
|
||||
|
|
@ -1151,7 +1216,7 @@ export default defineComponent({
|
|||
// hide."
|
||||
onSelect(key) {
|
||||
if (this.isBigPictureDisabled(key)) {
|
||||
this.disabledNotice = 'Big Picture only supports desktop — open this page on a desktop to use it.'
|
||||
this.disabledNotice = this.tr('desktopNotice')
|
||||
clearTimeout(this._noticeTimer)
|
||||
this._noticeTimer = setTimeout(() => { this.disabledNotice = '' }, 4000)
|
||||
return
|
||||
|
|
@ -1365,9 +1430,9 @@ export default defineComponent({
|
|||
Measured 2026-09-25 on a host with `body{font:20px/3 Georgia; letter-spacing:2px}`: the gallery
|
||||
root computed font-size 20px, line-height 60px, letter-spacing 2px — every rail row and map legend
|
||||
re-flowed by the partner's blog styles. These are reset to the values the gallery is designed
|
||||
against (its own leaf page's defaults). `direction` is deliberately NOT reset, so an RTL host page
|
||||
still lays the gallery out RTL; `all: initial` would have reset it too. Family and colour are set by
|
||||
the layout itself (.vtl). */
|
||||
against (its own leaf page's defaults). `direction` is not reset here: the layout root carries an
|
||||
explicit dir from the gallery's language (uiDir), which is what actually decides RTL. Family and
|
||||
colour are set by the layout itself (.vtl). */
|
||||
:host {
|
||||
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
|
||||
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
|
||||
|
|
|
|||
228
plugin-designs/doxa-maps-spa/src/strings.js
Normal file
228
plugin-designs/doxa-maps-spa/src/strings.js
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* strings.js — the Research Gallery's own words, in the 11 shipped locales.
|
||||
*
|
||||
* PRIVATE TO THIS PLUGIN, on purpose. These ~25 strings belong to the gallery shell alone (its rail,
|
||||
* sections and status hints), so they live in this folder rather than in library/i18n, whose engine
|
||||
* inlines every namespace into every bundle. Each nested map translates ITSELF; this only covers the
|
||||
* chrome around them.
|
||||
*
|
||||
* VOCABULARY FOLLOWS THE LIBRARY. Where a term already has a shipped translation it is reused verbatim,
|
||||
* so the rail row and the map it opens use the same words: legend.title.prayer (Prayer Progress),
|
||||
* legend.title.gospelResources (Gospel Resources), tabs.regions / tabs.countries, tabs.upg100Zone
|
||||
* (UPG-100), and tabs.group for "people group" (Pueblo, Peuple, Volksgruppe, …).
|
||||
*
|
||||
* `en` is the source and the fallback: a key missing in a locale renders in English rather than as a
|
||||
* key name. {n} and {label} are the only placeholders.
|
||||
*/
|
||||
export const STRINGS = {
|
||||
en: {
|
||||
'sec.dashboards': 'Dashboards', 'sec.prayer': 'Prayer Mobilization', 'sec.gospel': 'Gospel Research', 'sec.experimental': 'Experimental',
|
||||
'tab.dashboard-plus-grid': 'Big Picture', 'sub.dashboard-plus-grid': 'dashboard + people-groups grid, stacked',
|
||||
'tab.research-map': 'Research Map', 'sub.research-map': 'prayer · engagement · regions',
|
||||
'tab.research-map-plus-grid': 'Simple Research', 'sub.research-map-plus-grid': 'research map + people-groups grid',
|
||||
'tab.prayer-map': 'Prayer Progress', 'sub.prayer-map': 'simple world prayer map',
|
||||
'tab.cities-internet': 'Cities with Internet', 'sub.cities-internet': 'connectivity of key cities',
|
||||
'tab.gospel-resources': 'Gospel Resources', 'sub.gospel-resources': 'resources by place',
|
||||
'tab.regions-dashboard': 'Regions', 'sub.regions-dashboard': 'regions and their countries',
|
||||
'tab.upg-100': 'UPG-100 List', 'sub.upg-100': 'my unreached list + posters',
|
||||
navLabel: 'Research maps', meta: '{n} maps', loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
|
||||
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
|
||||
desktopOnly: 'desktop only', lab: 'lab', experimentalSub: 'experimental bundle',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/en/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Toggle list", openViews: "Open the list of views", expandList: "Expand list", collapseList: "Collapse list", collapse: "Collapse",
|
||||
desktopNotice: 'Big Picture only supports desktop — open this page on a desktop to use it.'
|
||||
},
|
||||
es: {
|
||||
'sec.dashboards': 'Paneles', 'sec.prayer': 'Movilización de oración', 'sec.gospel': 'Investigación del Evangelio', 'sec.experimental': 'Experimental',
|
||||
'tab.dashboard-plus-grid': 'Panorama general', 'sub.dashboard-plus-grid': 'panel + cuadrícula de pueblos',
|
||||
'tab.research-map': 'Mapa de investigación', 'sub.research-map': 'oración · compromiso · regiones',
|
||||
'tab.research-map-plus-grid': 'Investigación simple', 'sub.research-map-plus-grid': 'mapa de investigación + cuadrícula de pueblos',
|
||||
'tab.prayer-map': 'Progreso de oración', 'sub.prayer-map': 'mapa mundial de oración',
|
||||
'tab.cities-internet': 'Ciudades con Internet', 'sub.cities-internet': 'conectividad de ciudades clave',
|
||||
'tab.gospel-resources': 'Recursos del Evangelio', 'sub.gospel-resources': 'recursos por lugar',
|
||||
'tab.regions-dashboard': 'Regiones', 'sub.regions-dashboard': 'regiones y sus países',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
|
||||
navLabel: 'Mapas de investigación', meta: '{n} mapas', loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
|
||||
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
|
||||
desktopOnly: 'solo escritorio', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/es/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostrar u ocultar la lista", openViews: "Abrir la lista de vistas", expandList: "Expandir la lista", collapseList: "Contraer la lista", collapse: "Contraer",
|
||||
desktopNotice: 'Panorama general solo funciona en escritorio: abre esta página en una computadora para usarlo.'
|
||||
},
|
||||
fr: {
|
||||
'sec.dashboards': 'Tableaux de bord', 'sec.prayer': 'Mobilisation à la prière', 'sec.gospel': "Recherche sur l'Évangile", 'sec.experimental': 'Expérimental',
|
||||
'tab.dashboard-plus-grid': "Vue d'ensemble", 'sub.dashboard-plus-grid': 'tableau de bord + grille des peuples',
|
||||
'tab.research-map': 'Carte de recherche', 'sub.research-map': 'prière · engagement · régions',
|
||||
'tab.research-map-plus-grid': 'Recherche simple', 'sub.research-map-plus-grid': 'carte de recherche + grille des peuples',
|
||||
'tab.prayer-map': 'Progression de la prière', 'sub.prayer-map': 'carte mondiale de la prière',
|
||||
'tab.cities-internet': 'Villes avec Internet', 'sub.cities-internet': 'connectivité des villes clés',
|
||||
'tab.gospel-resources': "Ressources de l'Évangile", 'sub.gospel-resources': 'ressources par lieu',
|
||||
'tab.regions-dashboard': 'Régions', 'sub.regions-dashboard': 'régions et leurs pays',
|
||||
'tab.upg-100': 'Liste UPG-100', 'sub.upg-100': 'ma liste des non atteints + affiches',
|
||||
navLabel: 'Cartes de recherche', meta: '{n} cartes', loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
|
||||
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
|
||||
desktopOnly: 'ordinateur uniquement', lab: 'bêta', experimentalSub: 'carte expérimentale',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/fr/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Afficher ou masquer la liste", openViews: "Ouvrir la liste des vues", expandList: "Déplier la liste", collapseList: "Replier la liste", collapse: "Replier",
|
||||
desktopNotice: "Vue d'ensemble ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l'utiliser."
|
||||
},
|
||||
de: {
|
||||
'sec.dashboards': 'Dashboards', 'sec.prayer': 'Gebetsmobilisierung', 'sec.gospel': 'Evangeliumsforschung', 'sec.experimental': 'Experimentell',
|
||||
'tab.dashboard-plus-grid': 'Gesamtbild', 'sub.dashboard-plus-grid': 'Dashboard + Raster der Volksgruppen',
|
||||
'tab.research-map': 'Forschungskarte', 'sub.research-map': 'Gebet · Engagement · Regionen',
|
||||
'tab.research-map-plus-grid': 'Einfache Recherche', 'sub.research-map-plus-grid': 'Forschungskarte + Raster der Volksgruppen',
|
||||
'tab.prayer-map': 'Gebetsfortschritt', 'sub.prayer-map': 'einfache Weltkarte des Gebets',
|
||||
'tab.cities-internet': 'Städte mit Internet', 'sub.cities-internet': 'Konnektivität wichtiger Städte',
|
||||
'tab.gospel-resources': 'Evangeliumsressourcen', 'sub.gospel-resources': 'Ressourcen nach Ort',
|
||||
'tab.regions-dashboard': 'Regionen', 'sub.regions-dashboard': 'Regionen und ihre Länder',
|
||||
'tab.upg-100': 'UPG-100-Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
|
||||
navLabel: 'Forschungskarten', meta: '{n} Karten', loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
|
||||
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
|
||||
desktopOnly: 'nur Desktop', lab: 'Beta', experimentalSub: 'experimentelle Karte',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/de/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Liste ein-/ausblenden", openViews: "Ansichtsliste öffnen", expandList: "Liste ausklappen", collapseList: "Liste einklappen", collapse: "Einklappen",
|
||||
desktopNotice: 'Gesamtbild funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer, um es zu nutzen.'
|
||||
},
|
||||
pt: {
|
||||
'sec.dashboards': 'Painéis', 'sec.prayer': 'Mobilização de oração', 'sec.gospel': 'Pesquisa do Evangelho', 'sec.experimental': 'Experimental',
|
||||
'tab.dashboard-plus-grid': 'Visão geral', 'sub.dashboard-plus-grid': 'painel + grade de povos',
|
||||
'tab.research-map': 'Mapa de pesquisa', 'sub.research-map': 'oração · engajamento · regiões',
|
||||
'tab.research-map-plus-grid': 'Pesquisa simples', 'sub.research-map-plus-grid': 'mapa de pesquisa + grade de povos',
|
||||
'tab.prayer-map': 'Progresso da oração', 'sub.prayer-map': 'mapa mundial de oração',
|
||||
'tab.cities-internet': 'Cidades com Internet', 'sub.cities-internet': 'conectividade das principais cidades',
|
||||
'tab.gospel-resources': 'Recursos do Evangelho', 'sub.gospel-resources': 'recursos por lugar',
|
||||
'tab.regions-dashboard': 'Regiões', 'sub.regions-dashboard': 'regiões e seus países',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
|
||||
navLabel: 'Mapas de pesquisa', meta: '{n} mapas', loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
|
||||
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
|
||||
desktopOnly: 'só no computador', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/pt/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostrar ou ocultar a lista", openViews: "Abrir a lista de visualizações", expandList: "Expandir a lista", collapseList: "Recolher a lista", collapse: "Recolher",
|
||||
desktopNotice: 'Visão geral só funciona no computador — abra esta página em um computador para usá-la.'
|
||||
},
|
||||
it: {
|
||||
'sec.dashboards': 'Dashboard', 'sec.prayer': 'Mobilitazione alla preghiera', 'sec.gospel': 'Ricerca sul Vangelo', 'sec.experimental': 'Sperimentale',
|
||||
'tab.dashboard-plus-grid': "Quadro d'insieme", 'sub.dashboard-plus-grid': 'dashboard + griglia dei popoli',
|
||||
'tab.research-map': 'Mappa di ricerca', 'sub.research-map': 'preghiera · impegno · regioni',
|
||||
'tab.research-map-plus-grid': 'Ricerca semplice', 'sub.research-map-plus-grid': 'mappa di ricerca + griglia dei popoli',
|
||||
// Rail-length form. The library's legend title 'Avanzamento della preghiera' measured 191px in the
|
||||
// 176px rail label at both 1400px and 390px (2026-09-25 locale sweep) and was cut off.
|
||||
'tab.prayer-map': 'Avanzamento preghiera', 'sub.prayer-map': 'mappa mondiale della preghiera',
|
||||
'tab.cities-internet': 'Città con Internet', 'sub.cities-internet': 'connettività delle città chiave',
|
||||
'tab.gospel-resources': 'Risorse del Vangelo', 'sub.gospel-resources': 'risorse per luogo',
|
||||
'tab.regions-dashboard': 'Regioni', 'sub.regions-dashboard': 'regioni e i loro paesi',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
|
||||
navLabel: 'Mappe di ricerca', meta: '{n} mappe', loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
|
||||
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
|
||||
desktopOnly: 'solo desktop', lab: 'beta', experimentalSub: 'mappa sperimentale',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/it/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostra o nascondi l’elenco", openViews: "Apri l’elenco delle viste", expandList: "Espandi l’elenco", collapseList: "Comprimi l’elenco", collapse: "Comprimi",
|
||||
desktopNotice: "Quadro d'insieme funziona solo su desktop: apri questa pagina su un computer per usarlo."
|
||||
},
|
||||
ro: {
|
||||
'sec.dashboards': 'Panouri', 'sec.prayer': 'Mobilizare la rugăciune', 'sec.gospel': 'Cercetarea Evangheliei', 'sec.experimental': 'Experimental',
|
||||
'tab.dashboard-plus-grid': 'Imaginea de ansamblu', 'sub.dashboard-plus-grid': 'panou + grila popoarelor',
|
||||
'tab.research-map': 'Hartă de cercetare', 'sub.research-map': 'rugăciune · implicare · regiuni',
|
||||
'tab.research-map-plus-grid': 'Cercetare simplă', 'sub.research-map-plus-grid': 'hartă de cercetare + grila popoarelor',
|
||||
'tab.prayer-map': 'Progresul rugăciunii', 'sub.prayer-map': 'hartă mondială a rugăciunii',
|
||||
'tab.cities-internet': 'Orașe cu internet', 'sub.cities-internet': 'conectivitatea orașelor-cheie',
|
||||
'tab.gospel-resources': 'Resurse ale Evangheliei', 'sub.gospel-resources': 'resurse după loc',
|
||||
'tab.regions-dashboard': 'Regiuni', 'sub.regions-dashboard': 'regiunile și țările lor',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'lista mea de neatinși + afișe',
|
||||
navLabel: 'Hărți de cercetare', meta: '{n} hărți', loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
|
||||
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
|
||||
desktopOnly: 'doar pe desktop', lab: 'beta', experimentalSub: 'hartă experimentală',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ro/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Afișează sau ascunde lista", openViews: "Deschide lista de vizualizări", expandList: "Extinde lista", collapseList: "Restrânge lista", collapse: "Restrânge",
|
||||
desktopNotice: 'Imaginea de ansamblu funcționează doar pe desktop — deschideți această pagină pe un computer pentru a o folosi.'
|
||||
},
|
||||
ru: {
|
||||
'sec.dashboards': 'Панели', 'sec.prayer': 'Молитвенная мобилизация', 'sec.gospel': 'Исследование Евангелия', 'sec.experimental': 'Экспериментальное',
|
||||
'tab.dashboard-plus-grid': 'Общая картина', 'sub.dashboard-plus-grid': 'панель + таблица народов',
|
||||
'tab.research-map': 'Исследовательская карта', 'sub.research-map': 'молитва · вовлечённость · регионы',
|
||||
'tab.research-map-plus-grid': 'Простое исследование', 'sub.research-map-plus-grid': 'исследовательская карта + таблица народов',
|
||||
'tab.prayer-map': 'Прогресс молитвы', 'sub.prayer-map': 'простая карта молитвы мира',
|
||||
'tab.cities-internet': 'Города с интернетом', 'sub.cities-internet': 'подключённость ключевых городов',
|
||||
'tab.gospel-resources': 'Евангельские ресурсы', 'sub.gospel-resources': 'ресурсы по местам',
|
||||
'tab.regions-dashboard': 'Регионы', 'sub.regions-dashboard': 'регионы и их страны',
|
||||
'tab.upg-100': 'Список UPG-100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
|
||||
navLabel: 'Исследовательские карты', meta: 'Карт: {n}', loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
|
||||
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
|
||||
desktopOnly: 'только на компьютере', lab: 'бета', experimentalSub: 'экспериментальная карта',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ru/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Показать или скрыть список", openViews: "Открыть список представлений", expandList: "Развернуть список", collapseList: "Свернуть список", collapse: "Свернуть",
|
||||
desktopNotice: '«Общая картина» работает только на компьютере — откройте эту страницу на компьютере.'
|
||||
},
|
||||
ar: {
|
||||
'sec.dashboards': 'لوحات المعلومات', 'sec.prayer': 'التعبئة للصلاة', 'sec.gospel': 'أبحاث الإنجيل', 'sec.experimental': 'تجريبي',
|
||||
'tab.dashboard-plus-grid': 'الصورة الكاملة', 'sub.dashboard-plus-grid': 'لوحة معلومات + شبكة الشعوب',
|
||||
'tab.research-map': 'خريطة البحث', 'sub.research-map': 'الصلاة · الانخراط · المناطق',
|
||||
'tab.research-map-plus-grid': 'بحث مبسّط', 'sub.research-map-plus-grid': 'خريطة البحث + شبكة الشعوب',
|
||||
'tab.prayer-map': 'تقدم الصلاة', 'sub.prayer-map': 'خريطة عالمية مبسّطة للصلاة',
|
||||
'tab.cities-internet': 'مدن متصلة بالإنترنت', 'sub.cities-internet': 'اتصال المدن الرئيسية',
|
||||
'tab.gospel-resources': 'موارد الإنجيل', 'sub.gospel-resources': 'الموارد حسب المكان',
|
||||
'tab.regions-dashboard': 'المناطق', 'sub.regions-dashboard': 'المناطق ودولها',
|
||||
'tab.upg-100': 'قائمة UPG-100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
|
||||
navLabel: 'خرائط البحث', meta: 'عدد الخرائط: {n}', loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
|
||||
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
|
||||
desktopOnly: 'على الحاسوب فقط', lab: 'تجريبي', experimentalSub: 'خريطة تجريبية',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ar/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "إظهار القائمة أو إخفاؤها", openViews: "فتح قائمة العروض", expandList: "توسيع القائمة", collapseList: "طي القائمة", collapse: "طي",
|
||||
desktopNotice: 'تعمل «الصورة الكاملة» على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامها.'
|
||||
},
|
||||
hi: {
|
||||
'sec.dashboards': 'डैशबोर्ड', 'sec.prayer': 'प्रार्थना संचालन', 'sec.gospel': 'सुसमाचार शोध', 'sec.experimental': 'प्रायोगिक',
|
||||
'tab.dashboard-plus-grid': 'संपूर्ण चित्र', 'sub.dashboard-plus-grid': 'डैशबोर्ड + जनसमूहों की ग्रिड',
|
||||
'tab.research-map': 'शोध मानचित्र', 'sub.research-map': 'प्रार्थना · सहभागिता · क्षेत्र',
|
||||
'tab.research-map-plus-grid': 'सरल शोध', 'sub.research-map-plus-grid': 'शोध मानचित्र + जनसमूहों की ग्रिड',
|
||||
'tab.prayer-map': 'प्रार्थना प्रगति', 'sub.prayer-map': 'सरल विश्व प्रार्थना मानचित्र',
|
||||
'tab.cities-internet': 'इंटरनेट वाले शहर', 'sub.cities-internet': 'प्रमुख शहरों की कनेक्टिविटी',
|
||||
'tab.gospel-resources': 'सुसमाचार संसाधन', 'sub.gospel-resources': 'स्थान के अनुसार संसाधन',
|
||||
'tab.regions-dashboard': 'क्षेत्र', 'sub.regions-dashboard': 'क्षेत्र और उनके देश',
|
||||
'tab.upg-100': 'UPG-100 सूची', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
|
||||
navLabel: 'शोध मानचित्र', meta: '{n} मानचित्र', loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
|
||||
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
|
||||
desktopOnly: 'केवल डेस्कटॉप', lab: 'बीटा', experimentalSub: 'प्रायोगिक मानचित्र',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/hi/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "सूची दिखाएँ या छिपाएँ", openViews: "दृश्यों की सूची खोलें", expandList: "सूची फैलाएँ", collapseList: "सूची संक्षिप्त करें", collapse: "संक्षिप्त करें",
|
||||
desktopNotice: 'संपूर्ण चित्र केवल डेस्कटॉप पर काम करता है — इसे उपयोग करने के लिए यह पेज कंप्यूटर पर खोलें।'
|
||||
},
|
||||
zh: {
|
||||
'sec.dashboards': '仪表板', 'sec.prayer': '祷告动员', 'sec.gospel': '福音研究', 'sec.experimental': '实验性',
|
||||
'tab.dashboard-plus-grid': '全景', 'sub.dashboard-plus-grid': '仪表板 + 民族网格',
|
||||
'tab.research-map': '研究地图', 'sub.research-map': '祷告 · 参与 · 区域',
|
||||
'tab.research-map-plus-grid': '简易研究', 'sub.research-map-plus-grid': '研究地图 + 民族网格',
|
||||
'tab.prayer-map': '祷告进度', 'sub.prayer-map': '简易世界祷告地图',
|
||||
'tab.cities-internet': '有互联网的城市', 'sub.cities-internet': '主要城市的网络连接',
|
||||
'tab.gospel-resources': '福音资源', 'sub.gospel-resources': '按地点划分的资源',
|
||||
'tab.regions-dashboard': '区域', 'sub.regions-dashboard': '区域及其国家',
|
||||
'tab.upg-100': 'UPG-100 名单', 'sub.upg-100': '我的未得之民名单 + 海报',
|
||||
navLabel: '研究地图', meta: '{n} 张地图', loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
|
||||
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',
|
||||
desktopOnly: '仅限桌面', lab: '测试版', experimentalSub: '实验性地图',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/zh/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "显示或隐藏列表", openViews: "打开视图列表", expandList: "展开列表", collapseList: "折叠列表", collapse: "折叠",
|
||||
desktopNotice: '全景仅支持桌面设备——请在电脑上打开此页面使用。'
|
||||
}
|
||||
}
|
||||
|
||||
export const LOCALES = Object.keys(STRINGS)
|
||||
|
||||
/** Translate `key` into `locale`, falling back to English, then to the key itself. */
|
||||
export function translate(locale, key, params) {
|
||||
const table = STRINGS[locale] || STRINGS.en
|
||||
let s = table[key] ?? STRINGS.en[key] ?? key
|
||||
if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k]))
|
||||
return s
|
||||
}
|
||||
|
|
@ -22,4 +22,4 @@ another map. Two surfaces sit side by side:
|
|||
**Writing a strategy?** The full contract (required `export default` shape, checklist,
|
||||
profile-local overrides) is in
|
||||
**[../../../../documentation/architecture/color-strategies.md](../../../../documentation/architecture/color-strategies.md)**.
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../contributing/COLORS.md).
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../documentation/contributing/COLORS.md).
|
||||
|
|
|
|||
|
|
@ -370,12 +370,11 @@ const legendTitle = computed(() => 'Legend')
|
|||
// wiring for desktop, mobile and embeds. STL also retitles itself "Progress".
|
||||
const legendProgress = flatLegend.progress
|
||||
provide('legendProgress', legendProgress)
|
||||
// From the catalog (legend.colLabel.*), which every locale already translates. This was
|
||||
// hard-coded English, the one column header left in English in the ar/zh/ru/hi embeds.
|
||||
const legendColumnLabel = computed(() => {
|
||||
const t = activeLegendType.value
|
||||
if (t === 'prayer') return 'Prayer Status'
|
||||
if (t === 'engagement') return 'Engagement Status'
|
||||
if (t === 'adoption') return 'Adoption Status'
|
||||
return 'Status'
|
||||
const type = activeLegendType.value
|
||||
return t('legend.colLabel.' + (['prayer', 'engagement', 'adoption'].includes(type) ? type : 'status'))
|
||||
})
|
||||
const _stlOpen = computed(() => uiStore.legendState !== 'collapsed')
|
||||
function _onStlOpenChange(open) {
|
||||
|
|
|
|||
|
|
@ -22,4 +22,4 @@ another map. Two surfaces sit side by side:
|
|||
**Writing a strategy?** The full contract (required `export default` shape, checklist,
|
||||
profile-local overrides) is in
|
||||
**[../../../../documentation/architecture/color-strategies.md](../../../../documentation/architecture/color-strategies.md)**.
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../contributing/COLORS.md).
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../documentation/contributing/COLORS.md).
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
# Resource sources checked and REJECTED — do not re-walk these
|
||||
|
||||
Written after a tick spent proving a promising source unusable. Each entry below looked
|
||||
like real coverage and is not. The point of this file is that the next agent spends the
|
||||
tick on something else.
|
||||
|
||||
## Scripture Earth (scriptureearth.org) — REJECTED
|
||||
Looks like the biggest win available: `/data/` is an open Apache index with **3,477
|
||||
ISO 639-3 directories**, more than Arclight (2,087) and eBible (1,237) combined, and
|
||||
**730 of them are languages currently showing "nothing found"** on our map.
|
||||
|
||||
It is not coverage — **the directories are overwhelmingly EMPTY**:
|
||||
- **MEASURED on a random sample of 20 of those 730 tier-0 languages: 19 are empty.**
|
||||
No video files, mostly no subdirectories at all. A code directory is a roster row.
|
||||
Extrapolated, perhaps ~5% (~37 languages) hold something real; finding them means
|
||||
crawling all 730 directories, which is a legitimate future option but a small return.
|
||||
- **Its language codes disagree with the publisher's.** It files Ghotuo under `aaa`;
|
||||
the JESUS Film Project's own API files it under `ikh`.
|
||||
|
||||
### CORRECTION — an earlier version of this file said its links were DEAD. That was wrong.
|
||||
The claim was that `/watch/jesus.html/<slug>.html` is a retired scheme because
|
||||
`english.html` 404s. It 404s **to a server-side fetch only**. Loaded in a browser, both
|
||||
`jesus.html/english.html` and the Ghotuo link render "FEATURE FILM JESUS · 128 min FHD",
|
||||
while a bogus slug renders "404 PAGE NOT FOUND". The scheme is alive.
|
||||
|
||||
The lesson is one this file already records for `?languageId=` and I applied inconsistently:
|
||||
**no jesusfilm.org URL can be graded by fetching.** The site is client-routed, so a fetch
|
||||
404s pages that work and returns a shell for pages that do not. Grade in a browser, or by
|
||||
the `1_jf-0-0` marker, and never by status.
|
||||
|
||||
## GRN / globalrecordings.net — BLOCKED, worth pursuing by permission
|
||||
Behind a Bunny Shield proof-of-work challenge; `robots.txt` itself returns the challenge,
|
||||
so their crawl policy cannot even be read. Individual language pages resolve and carry a
|
||||
real signal (`/en/program/` row counts: cmn 86, mjl 6, invalid 0), so the data is there.
|
||||
Re-probed and still 403 on every path including `robots.txt`. It needs a credential or an
|
||||
agreement, not a scraper.
|
||||
|
||||
It is no longer the largest remaining gap, though, because GRN was never the only audio
|
||||
pool — see FCBH below. The honest statement on a pin is now "none at FCBH, GRN
|
||||
unsearchable", not "not searched".
|
||||
|
||||
## Key-gated, not rejected — need credentials
|
||||
- `api.bible` (American Bible Society) — 401.
|
||||
- FCBH / `4.dbt.io` — 422, still key-gated. **But the API was never the only way in: see the
|
||||
FCBH section below.** Note `FCBHID` is present on ALL 1,550 eBible rows, so it is an
|
||||
identifier mapping, NOT evidence a recording exists. Do not treat it as audio coverage.
|
||||
- Digital Bible Library — `public_domain_entries.json` returns the app shell; needs an account.
|
||||
- Deaf Bible Society (deafbible.com) — key-gated app. DOOR International — 403.
|
||||
|
||||
## Faith Comes By Hearing — SOLVED, and no key was needed
|
||||
Their `4.dbt.io` API wants a key and answers 422 without one, which is where the earlier
|
||||
note stopped. Their own recordings-database page does not use that API: it is
|
||||
client-rendered and fetches a STATIC public file from their CDN,
|
||||
`cdn.faithcomesbyhearing.com/resources/fcbh/js/library/bible_recordings.json` — 3,602 rows
|
||||
of `{ iso, lang_name, bible_name, bible_id, country, video_date, audio_date, text_date }`,
|
||||
already keyed by ISO 639-3. 2,192 codes carry a dated audio recording.
|
||||
|
||||
**Verification now uses their API, not a body-size heuristic.** The page still answers 200
|
||||
for any fileset id, so status on the PAGE is meaningless — the first version of this check
|
||||
compared body sizes against a shell measured at run time. That worked, but it was a heuristic
|
||||
on a number the site controls, and it stopped working the moment their WAF began answering
|
||||
HEAD with 403 and a 520-byte body for real and bogus alike. Their own API needs no heuristic:
|
||||
|
||||
GET /api/bibles/OKEBSN -> 200, 1,876 bytes (real fileset)
|
||||
GET /api/bibles/ZZZZZZ -> 404, 75 bytes (bogus)
|
||||
|
||||
It 404s properly, it is a tenth of the page's weight, and it describes exactly the fileset
|
||||
the shipped page renders. GET is used rather than HEAD because HEAD is what the WAF blocks.
|
||||
The in-band control now asserts 200-for-real and 404-for-bogus and aborts otherwise.
|
||||
|
||||
**A 200 from live.bible.is proves nothing, and this almost shipped wrong.** It is a Next.js
|
||||
app that answers 200 for a fileset that does not exist, returning a fixed-size not-found
|
||||
shell:
|
||||
|
||||
bible/OKEBSN (real) -> 200, content-length 15,996
|
||||
bible/KHEYBI (real) -> 200, content-length 76,547
|
||||
bible/ZZZZZZ (bogus) -> 200, content-length 7,961 <- on the /MAT/1 path
|
||||
bible/ZZZZZZ (bogus) -> 200, content-length 7,860 <- on the bare path
|
||||
|
||||
So the discriminator is BODY SIZE, not status — and HEAD returns `content-length`, so it
|
||||
costs one request and no payload. The shell size is measured at run time rather than
|
||||
hard-coded, and the two different values above are exactly why: a constant would have been
|
||||
wrong within the same hour. If a bogus id ever returns the same size as a known-good one the
|
||||
discriminator has stopped discriminating, and both the fetcher and the verifier abort rather
|
||||
than write thousands of false greens.
|
||||
|
||||
**Text is included, video is NOT.** `text_date` is unambiguous — FCBH publishes Bible text
|
||||
on bible.is — and 698 codes carry text where this map had no text Scripture at all, reaching
|
||||
2,176 pins and 1.38bn people. Media now break down as 1,465 codes audio+text, 677 audio only,
|
||||
207 text only, and each link says which it is rather than all of them claiming "audio".
|
||||
`video_date` is now INCLUDED, and establishing what it is took measurement rather than
|
||||
inference. Rendering three video-only filesets showed `...S2DV` pages opening on "Intro &
|
||||
Garden of Eden" with Drama/Non-Drama controls, and `GET /api/bibles/<bible_id>` returns the
|
||||
exact `books` array in one call. The first samples were unrepresentative and would have led
|
||||
to a wrong generalisation:
|
||||
|
||||
110 languages 12 parts, Intro & Garden of Eden to After the Forty Years (Covenant, OT)
|
||||
209 languages 27 parts, Matthew to Revelation (whole NT)
|
||||
116 languages 27 parts, San Mateo/SAN MATEO/Mateo to Apocalipsis (whole NT, es)
|
||||
27 / 26 / 23 Mark / Acts / Luke
|
||||
1,006 distinct scopes in all
|
||||
|
||||
So the bulk of it is the New Testament on video, not an Old Testament narrative — which is
|
||||
precisely why the scope is read per fileset and shipped with the link instead of being
|
||||
described once from a sample.
|
||||
|
||||
It is typed **"Bible video", never "Film"**. On this map film means the JESUS Film, and
|
||||
bible.is itself treats the two as different things: its pages call a separate Arclight
|
||||
endpoint, `/api/arclight/jesus-film/languages?iso=...`, to find the JESUS Film. It lifts a
|
||||
group off "nothing found", which is true, and contributes nothing to the Scripture+film tier.
|
||||
1,666 languages carry it; 287 as a separate page, 1,379 merged into the Scripture link whose
|
||||
media list now says "audio, text, video". 3 were dropped because their book list could not be
|
||||
read — an undescribed video is not shipped.
|
||||
|
||||
**Two failures worth recording.** A `fetch` with no timeout hung the fetcher for thirteen
|
||||
minutes with no output; a stall looks exactly like slow work, so every fetch here now carries
|
||||
one. And after 1,669 API calls live.bible.is began returning 403 with a 520-byte body for
|
||||
BOTH a real and a bogus fileset — at which point the size discriminator stops discriminating.
|
||||
The run-time calibration caught it and refused to write rather than marking thousands of
|
||||
links verified. That is the whole reason the shell size is measured instead of hard-coded.
|
||||
|
||||
One link per language, not one per medium: a bible.is fileset is a single page carrying
|
||||
whatever media it has, so two chips pointing at the same URL would be noise. Rows are ranked
|
||||
by media count, then by whether audio is present, then by recency — the middle term is not
|
||||
decoration, because without it a newer text-only fileset outranks an audio-only one on date
|
||||
alone and the language silently loses its recording, which is the medium an oral-preference
|
||||
group actually needs. Measured when it was missing: 3 codes lost audio.
|
||||
|
||||
Doctrine: straight Scripture audio and text, which the brief names as acceptable, from Bible
|
||||
Societies, Biblica, Wycliffe and Covenant. The name screen is a guard for future data, not a fix for
|
||||
anything present — 0 of 2,769 distinct translation names match it.
|
||||
|
||||
## Arclight beyond featureFilm — checked, and there is almost nothing there
|
||||
The Arclight harvest keeps a language only when `counts.featureFilm >= 1`, and it was worth
|
||||
asking whether that filter was discarding real coverage. It is not. Of 2,330 languages in
|
||||
the catalogue, **2,309 already have a feature film**. Languages carrying only a `shortFilm`
|
||||
or a `series` number 15, and of those exactly **one** — `nmg` Kwasio, 2 pins, 60k people —
|
||||
is spoken by a group on this map that has nothing else.
|
||||
|
||||
So the filter was already near-complete, and widening it would mean typing a short film as
|
||||
"Film" beside the JESUS Film for a single language. Left as it is.
|
||||
|
||||
## Sign language — the open catalogues are EXHAUSTED, and that is the finding
|
||||
136 of the 150 sign languages on this map have no resource at all, across 234 people groups.
|
||||
Searched this tick rather than assumed:
|
||||
|
||||
- **Arclight / JESUS Film** lists exactly **14** sign-language entries across **13** ISO codes with `featureFilm >= 1`, and
|
||||
every one of them is already in this index with a film link. (They dedupe to 13 ISO codes:
|
||||
`jos` is shared by Lebanese and Jordanian Sign Language.)
|
||||
- **Faith Comes By Hearing** carries **none** — 0 sign languages in 3,602 rows.
|
||||
- **deaf.bible** and **deafbible.com** both return the same 3,894-byte app shell.
|
||||
- **door.international** returns 114 bytes.
|
||||
|
||||
So this gap does not close by searching harder. It closes by an agreement with **Deaf Bible
|
||||
Society** or **DOOR International**, and the map now says so on every sign-language group
|
||||
instead of implying otherwise.
|
||||
|
||||
The provenance line for a sign language is also medium-corrected. The generic one claimed
|
||||
eBible, Door43 and FCBH audio as "checked" — none of which serves a Deaf group — and listed
|
||||
GRN, an audio pool, as the thing still unchecked. A Deaf group now reads `arclight (14 sign
|
||||
languages, all captured), fcbh (none)` as checked and `deaf_bible_society,
|
||||
door_international — both key-gated` as not.
|
||||
|
||||
**Jehovah's Witnesses publish extensively in sign language and are excluded regardless.**
|
||||
The size of a gap is not a reason to relax the doctrinal filter.
|
||||
|
||||
## WordProject — real, allowed, and not worth joining
|
||||
`robots.txt` permits everything except `/database/` and `/scripts/`, and
|
||||
`wordproject.org/bibles/index.htm` is a real 30KB page listing **78** Bible languages. It was
|
||||
rejected on the join, not on doctrine or access:
|
||||
|
||||
- Only **7** of its 78 directory codes are literally an ISO 639-3 this map carries
|
||||
(`ace`, `ben`, `ceb`, `guj`, `lug`, `mar`, `tel`). The rest are house codes — `al` for
|
||||
Albanian, `cr` Croatian, `cz` Czech, `dk` Danish, `gb` and `big5` for Chinese.
|
||||
- **Zero** of those 7 reach a group that has nothing; all 78 are major languages already
|
||||
covered here.
|
||||
- Matching the other 71 would mean joining on the English language NAME, which this map
|
||||
refuses everywhere else because names collide across unrelated languages. Taking that
|
||||
risk to duplicate Scripture links that already exist is a bad trade.
|
||||
|
||||
Also noted for whenever it is revisited: the list includes the Douay-Rheims and the Si Gao
|
||||
version, both of which carry the deuterocanon. The standing rule here is the one written for
|
||||
eBible — a publication carrying the 66 books plus an appendix still delivers the 66 books,
|
||||
and the type matters rather than the appendix — but where a 66-book edition of the same
|
||||
language exists it should be preferred.
|
||||
|
||||
## GodTools / Cru — reaches nobody who has nothing
|
||||
Integrated as gospel presentations (see the commit), but worth recording separately: of its
|
||||
105 ISO-joined languages, **0** reach a group at tier 0. Like WordProject, it adds depth to
|
||||
languages that are already served.
|
||||
|
||||
## Not a source at all
|
||||
- **sign.bible** — a parked domain listed for sale on Epik. Answers 200.
|
||||
|
||||
## YouVersion / bible.com — BLOCKED by a bot wall
|
||||
Named in the brief and tried. www.bible.com/languages, /versions and /api/bible/configuration
|
||||
all return an IDENTICAL 3,038-byte page titled "Client Challenge", and robots.txt itself
|
||||
returns that challenge HTML rather than a robots file — so their crawl policy cannot be read.
|
||||
Same shape as GRN. Needs an API agreement, not a scraper.
|
||||
|
||||
## zume.training — SOLVED, and the earlier note here was wrong
|
||||
This section previously said the language list was "no list obtainable". Both facts it
|
||||
rested on are true — `/api/` is `Disallow`ed in robots.txt, and the homepage's language
|
||||
switcher is client-rendered with no anchors to scrape — but the conclusion did not follow.
|
||||
Two public, explicitly-allowed sources carry the list:
|
||||
|
||||
- `https://zume.training/sitemap.xml`, which robots.txt itself **declares**, is a sitemap
|
||||
index with one per-locale sitemap for every locale that has public pages (54).
|
||||
- `https://zume.training/articles/zume-languages` is a page the publisher maintains for
|
||||
exactly this purpose: "All Zume languages (59)", with English name, native name and
|
||||
language code for each.
|
||||
|
||||
Neither is behind a `Disallow`, and the real figure is larger than the brief's estimate of
|
||||
~40: 62 distinct locales across the two sources, 54 of which serve a landing page that
|
||||
answers 200.
|
||||
|
||||
**The two sources disagree in both directions, so both are used.** Dari (`prs`) and Pashto
|
||||
(`pus`) ship public sites but are absent from the article's table; `ar_ma`, `az`, `bg`,
|
||||
`fo`, `lv`, `mn`, `tl` and `uk` are in the table but serve no page yet. `fetch-zume-index.mjs`
|
||||
takes the union and then **fetches every landing URL**, so a locale ships a link only if its
|
||||
own page actually answered — naming and linking are kept as separate facts rather than one
|
||||
assumed from the other.
|
||||
|
||||
**Pashto is not joined EXACTLY, and is shipped as an approximate match.** `pus` is a
|
||||
macrolanguage and the people-group data carries three individual codes (`pbu`, `pbt`, `pst`).
|
||||
What was checked for something that selects one:
|
||||
|
||||
- the publisher's launch article scopes Pashto as "Afghanistan's other official language and
|
||||
a major language of northwestern Pakistan, especially Khyber Pakhtunkhwa and parts of
|
||||
Balochistan" — territory spanning all three codes, not one;
|
||||
- the locale pages declare no language metadata at all: no `hreflang` alternates, no BCP-47
|
||||
tag, and `og:locale` is the site-wide `en_US` on every locale including this one.
|
||||
|
||||
So nothing promotes it to an exact join. But leaving it out entirely was itself wrong: it
|
||||
made 13 people groups covering 47.6M speakers read "not in Zume's 54" while
|
||||
`https://zume.training/pus` was live and in Pashto. Absence of a variety label is not
|
||||
absence of a resource.
|
||||
|
||||
It therefore ships as an **approximate** match — its own field, its own dashed chip, its own
|
||||
scope text ("Course in Pashto — variety not stated"), never counted as an exact join and
|
||||
never able to move a tier. Clause J of the coverage gate fails if a code ever carries both a
|
||||
confirmed and an approximate training link, because that is the corruption that would make
|
||||
the qualified claim render as the confirmed one.
|
||||
|
||||
The reason this is defensible for Zume and would NOT be for the JESUS Film: Zume in Pashto is
|
||||
a written course in one literary standard, where the pbu/pbt/pst split is largely spoken
|
||||
variety. The JESUS Film is already in this index under separate per-variety dubs
|
||||
(`pashto-yusufzai`, `pashto-southern`) because a dub genuinely differs by pronunciation. The
|
||||
medium decides.
|
||||
|
||||
**Zume moves no tier and shrinks no gap.** It is discipleship training, not Scripture and not
|
||||
film, so it is carried in its own field and typed "Discipleship training". Measured: its 52
|
||||
joined languages reach 3,479 pins and about 4.78bn people, and **none of them are tier 0** —
|
||||
every Zume language already has Scripture or film. Folding training into the tiers would have
|
||||
cut the gap count without anyone having translated anything.
|
||||
|
||||
## jesusfilm.org watch URLs — SOLVED, use the API's shareUrl
|
||||
`https://api.arclight.org/v2/media-components/1_jf-0-0/languages/<languageId>` returns a
|
||||
**shareUrl**, and that IS a real deep link: `jesusfilm.org/watch/jesus/<language-slug>`.
|
||||
Verified in a browser — it renders "FEATURE FILM JESUS · Watch now · 128 min" while a
|
||||
bogus slug renders "404 PAGE NOT FOUND". 2,082 of 2,087 languages have one; the other 5
|
||||
keep the old link and say so in their tooltip.
|
||||
|
||||
Verify these by fetching for the string `1_jf-0-0`: present on a real page, absent on a
|
||||
bogus slug. That is the ONLY jesusfilm.org check that discriminates.
|
||||
|
||||
## arc.gt short links — VERIFIED GOOD, 33 of ours use them
|
||||
Arclight returns an `arc.gt` shortlink as the shareUrl for some languages, notably sign
|
||||
languages (ASL, Russian SL, Iranian SL). They resolve: arc.gt/gt0oh renders "FEATURE FILM
|
||||
JESUS · Watch now · 119 min SD" for American Sign Language. A bogus keyword returns
|
||||
`{"error":"Keyword not found or invalid"}` — JSON, not a 404 page, so grade these by that
|
||||
string rather than by a page-not-found regex.
|
||||
|
||||
## jesusfilm.org ?languageId= — SUPERSEDED, never verifiable
|
||||
Kept, but do not "verify" them by fetching: `?languageId=` is a query param on a
|
||||
client-rendered page and the server returns a byte-identical shell for every id. Status
|
||||
grades everything ALIVE, content grades everything DEAD (the string "WatchNotFound" is in
|
||||
an i18n bundle on every page), and in a browser a real id and a bogus id render the same
|
||||
library. The Arclight API is the only thing that discriminates. The chip tooltip now says
|
||||
the link is not language-specific rather than implying it is.
|
||||
|
|
@ -30,6 +30,12 @@ const isDark = computed(() => profileConfig.value?.isDark === true)
|
|||
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
|
||||
|
||||
const TABS = [
|
||||
// ★ THE DOXA TABLE LEADS, because it is the question the Driver actually asked: which of
|
||||
// THEIR people groups have gospel resources and which do not. Every other tab here is
|
||||
// scoped to the research corpus — 12,379 groups, 4,231 languages — which is the right
|
||||
// scope for finding what exists and the wrong one for working a list of 2,107. A
|
||||
// mobiliser cannot plan from a table that is four-fifths other people's groups.
|
||||
{ id: 'doxa', label: 'Doxa — Gospel Access', file: 'doxa-gospel-access' },
|
||||
// THE LINKS lead. Every other table here is keyed by PEOPLE GROUP and answers "who has
|
||||
// nothing"; this one is keyed by the RESOURCE and answers "what exists and where do I
|
||||
// open it" — the question a mobiliser asks first, and the only table whose every row
|
||||
|
|
@ -130,7 +136,7 @@ const rawTable = computed(() => cache.value[current.value?.file] || null)
|
|||
// LANGUAGE is spoken in); the gaps table is one row per people-group-IN-COUNTRY and
|
||||
// carries iso3 directly. Filtering only the links table meant a country click could show
|
||||
// you what a country HAS and never what it lacks — which is the half a mobiliser came for.
|
||||
const FILTERABLE = new Set(['links', 'nothing'])
|
||||
const FILTERABLE = new Set(['doxa', 'links', 'nothing'])
|
||||
const table = computed(() => {
|
||||
const t = rawTable.value
|
||||
if (!t || !hasFilter.value || !FILTERABLE.has(current.value?.id)) return t
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
* the key doubles as a filter so the gap can be looked at on its own, and a shared URL
|
||||
* reopens the group you were looking at.
|
||||
*
|
||||
* The country shading is an AVERAGE across a country's groups and the dots are
|
||||
* individual groups, which is why a red dot can sit on a green country — that case is
|
||||
* the point of the map, not a rendering fault.
|
||||
* The country shading is a COUNT of Doxa's groups with nothing found — never a share,
|
||||
* because 106 of their 175 countries hold one or two groups and a percentage over that
|
||||
* denominator is noise. The dots are individual groups, which is why a dark dot can sit
|
||||
* on a light country — that case is the point of the map, not a rendering fault. A
|
||||
* country Doxa does not work is left uncoloured, not drawn as covered.
|
||||
*
|
||||
* > design note 2026-08-01: "mapping out the gospel resources is super important for me
|
||||
* > … key city posters, cities with internet, gospel resources — all these are
|
||||
|
|
@ -69,6 +71,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'
|
||||
|
||||
|
|
@ -110,50 +114,74 @@ const err = ref(null)
|
|||
const mode = ref('gap')
|
||||
|
||||
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
|
||||
/**
|
||||
* ═══ THE COLOUR SCALE, AND WHY IT COUNTS INSTEAD OF DIVIDING ════════════════════════════
|
||||
*
|
||||
* The old ramp painted a country by the SHARE of its groups with nothing found. Measured
|
||||
* against Doxa's roster that is not a statistic, it is noise: 106 of their 175 countries
|
||||
* hold one or two groups, and the United States holds three — Coquille, Pequots, Umpqua.
|
||||
* One of three without a resource is 33%, so the whole United States rendered amber next
|
||||
* to India, which holds 855. The map read as "most of the world is in crisis" because of
|
||||
* arithmetic, not because of the world. Swapping in a prettier ramp would have kept the
|
||||
* lie and made it handsomer.
|
||||
*
|
||||
* So the scale is a COUNT: how many of Doxa's groups in this country have nothing found.
|
||||
* A count cannot be inflated by a small denominator, it is what a team would actually plan
|
||||
* around, and it puts India, Sudan and Laos where they belong.
|
||||
*
|
||||
* OUT OF SCOPE IS NOT ZERO. A country with no Doxa groups gets NO FILL at all. Painting it
|
||||
* "green/covered" claimed a finding about a place this dataset says nothing about, which is
|
||||
* the same class of error as drawing an unmeasured figure as a zero.
|
||||
*
|
||||
* THE RAMP. Single family, light to dark, on the house light palette — sage for "every Doxa
|
||||
* group here has something", then four steps of clay deepening with the count. No yellow:
|
||||
* a mid-spectrum yellow over a pale basemap is what made the old map look jaundiced, and it
|
||||
* also carries the least luminance contrast of any hue against #f3f3f1. Sage and clay sit
|
||||
* far apart in hue AND in lightness, so the scale survives being read without colour.
|
||||
*/
|
||||
const NO_DOXA_FILL = 'rgba(0,0,0,0)'
|
||||
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' },
|
||||
{ id: 'd0', labelKey: 'gospel.legend.doxa0', color: '#92b195', min: 0, max: 0 },
|
||||
{ id: 'd1', labelKey: 'gospel.legend.doxa1', color: '#e8d9cd', min: 1, max: 4 },
|
||||
{ id: 'd5', labelKey: 'gospel.legend.doxa5', color: '#cfa388', min: 5, max: 14 },
|
||||
{ id: 'd15', labelKey: 'gospel.legend.doxa15', color: '#b06f57', min: 15, max: 39 },
|
||||
{ id: 'd40', labelKey: 'gospel.legend.doxa40', color: '#7d3b30', min: 40, max: Infinity },
|
||||
]
|
||||
// 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.
|
||||
/* Coverage mode asks the opposite question over the same roster: how many Doxa groups here
|
||||
DO have something. Same count logic, same reason, mirrored ramp. */
|
||||
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: 'c1', labelKey: 'gospel.legend.cvd1', color: '#e8d9cd', min: 1, max: 4 },
|
||||
{ id: 'c5', labelKey: 'gospel.legend.cvd5', color: '#a8c4a9', min: 5, max: 14 },
|
||||
{ id: 'c15', labelKey: 'gospel.legend.cvd15', color: '#6f9673', min: 15, max: 39 },
|
||||
{ id: 'c40', labelKey: 'gospel.legend.cvd40', color: '#3b463d', min: 40, max: Infinity },
|
||||
]
|
||||
const bandFor = (list, n) => list.find((b) => n >= b.min && n <= b.max) || null
|
||||
/** A country's fill. Null tally = Doxa does not work here = no fill, never a colour. */
|
||||
const featureColor = (iso3) => {
|
||||
const t = doxaByCountry.value[iso3]
|
||||
if (!t) return NO_DOXA_FILL
|
||||
const n = mode.value === 'gap' ? t.none : t.groups - t.none
|
||||
const b = bandFor(mode.value === 'gap' ? BANDS : COVER_BANDS, n)
|
||||
return b ? b.color : NO_DOXA_FILL
|
||||
}
|
||||
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || NO_DOXA_FILL
|
||||
|
||||
const activeBands = computed(() => (mode.value === 'gap' ? BANDS : COVER_BANDS))
|
||||
|
||||
// PIN TIERS reuse the band hexes above rather than declaring a fourth palette, so a pin
|
||||
// and the country under it never disagree about what "nothing found" looks like. Read
|
||||
// straight off BANDS so editing a band moves the pins with it.
|
||||
// PIN TIERS ARE THEIR OWN SCALE NOW, and deliberately so. They used to index into BANDS,
|
||||
// which was safe while both scales measured the same thing. They no longer do: a band is a
|
||||
// COUNT of Doxa groups in a country, a pin is what ONE group HAS. Indexing a count ramp by
|
||||
// a tier would have painted "nothing found" in the colour of "1-4 groups" — two different
|
||||
// facts wearing one colour, which is the disagreement that comment was written to prevent.
|
||||
// Dark green reads as complete, sage as partial, oxblood as empty; all three sit apart from
|
||||
// the clay country ramp in hue and in lightness, so a pin never dissolves into its country.
|
||||
const PIN_COLOR = {
|
||||
2: BANDS[3].color, // Scripture AND film in their own language
|
||||
1: BANDS[2].color, // one of the two
|
||||
0: BANDS[0].color, // nothing found in the pools checked — the pin that matters most
|
||||
2: '#3b463d', // Scripture AND film in their own language
|
||||
1: '#92b195', // one of the two
|
||||
0: '#7d3b30', // nothing found in the pools checked — the pin that matters most
|
||||
}
|
||||
|
||||
|
||||
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || '#9aa2ad'
|
||||
// Share of a country's groups with ANY resource found, derived from the same counts
|
||||
// the gap band uses — so the two modes can never disagree about the same country.
|
||||
const coverPct = (d) => {
|
||||
const g = Number(d?.groups) || 0
|
||||
if (!g) return null
|
||||
return ((g - (Number(d?.no_resource) || 0)) / g) * 100
|
||||
}
|
||||
const coverColor = (d) => {
|
||||
const p = coverPct(d)
|
||||
if (p === null) return '#9aa2ad'
|
||||
if (p >= 80) return '#10b981'
|
||||
if (p >= 50) return '#e8c15a'
|
||||
if (p > 0) return '#e07a2b'
|
||||
return '#b42318'
|
||||
}
|
||||
const featureColor = (d) => (mode.value === 'gap' ? bandColor(d.band) : coverColor(d))
|
||||
|
||||
const byIso = computed(() => {
|
||||
const m = {}
|
||||
for (const r of rows.value) m[r.iso3] = r
|
||||
|
|
@ -170,6 +198,9 @@ const pgLoading = ref(false)
|
|||
const pgErr = ref(null)
|
||||
const selectedGroup = ref(null) // set = the people-group detail is showing
|
||||
const pinRows = shallowRef([]) // [name, lat, lng, iso3, code, population, tier]
|
||||
const doxaTotal = ref(0) // every group on Doxa's roster, placed or not
|
||||
const doxaUnplaced = ref(0) // on the roster, no coordinates in this build yet
|
||||
const doxaByCountry = shallowRef({})
|
||||
const pinCodes = ref({})
|
||||
const pinBridges = ref({}) // '<iso639-3>|<ISO3 country>' -> the in-country bridge, if any
|
||||
const pinCount = ref(0)
|
||||
|
|
@ -597,6 +628,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(', ')
|
||||
|
||||
|
|
@ -720,11 +787,13 @@ const sameLanguage = computed(() => {
|
|||
|
||||
|
||||
const verifyOf = (url) => (url && linkVerify.value ? linkVerify.value[url] : null) || null
|
||||
/* labelKey, not label: a chip reads "<resource> · <state>" and BOTH halves are chrome, so
|
||||
both come from the catalogue. The class stays as it is — it is styling, not language. */
|
||||
const VSTATE = {
|
||||
verified: { cls: 'ok', label: 'link checked' },
|
||||
broken: { cls: 'bad', label: 'link dead' },
|
||||
mismatch: { cls: 'warn', label: 'wrong language' },
|
||||
unknown: { cls: 'meh', label: 'unchecked' },
|
||||
verified: { cls: 'ok', labelKey: 'gospel.state.linkChecked' },
|
||||
broken: { cls: 'bad', labelKey: 'gospel.state.linkDead' },
|
||||
mismatch: { cls: 'warn', labelKey: 'gospel.state.wrongLanguage' },
|
||||
unknown: { cls: 'meh', labelKey: 'gospel.state.unchecked' },
|
||||
}
|
||||
// A URL with no verification entry is UNCHECKED — never quietly shown as working.
|
||||
const vchip = (url) => VSTATE[verifyOf(url)?.state || 'unknown'] || VSTATE.unknown
|
||||
|
|
@ -748,7 +817,7 @@ function openGroup(g) { selectedGroup.value = g }
|
|||
function closeSheet() { selected.value = null; selectedGroup.value = null }
|
||||
|
||||
async function load() {
|
||||
const [data, adm0, pinData] = await Promise.all([
|
||||
const [data, adm0, pinData, doxaData] = await Promise.all([
|
||||
fetch(`${assetsBase.value}/tables/gospel-by-country.json`).then((r) => r.json()),
|
||||
// world-adm0 now INCLUDES India, so there is one ADM0 file and no patched
|
||||
// variant. It used to omit IND (the 100-list splits India into 10 provincial
|
||||
|
|
@ -762,12 +831,40 @@ async function load() {
|
|||
// than behind a tap. URLs are normalised into `codes` by ISO 639-3, so the 12,379
|
||||
// pins carry a code rather than 12,379 copies of the same jesusfilm.org link.
|
||||
fetch(`${assetsBase.value}/tables/people-group-pins.json`).then((r) => r.json()),
|
||||
/* ═══ THE MAP DRAWS DOXA'S ROSTER, NOT EVERY PEOPLE GROUP ON EARTH ═══════════════
|
||||
* people-group-pins.json stays exactly as it is — 12,379 groups, 8,437 verified
|
||||
* links, the whole GRN sweep — because it is the RESEARCH RECORD and the resource
|
||||
* index every sheet reads. What changed is what gets DRAWN. Doxa works the ~2,100
|
||||
* groups on pray.doxa.life, and a map of everyone buries the ones they can reach.
|
||||
* Nothing is lost; the wider set simply stops being dumped on the map. */
|
||||
fetch(`${assetsBase.value}/tables/doxa-people-groups.json`).then((r) => r.json()),
|
||||
])
|
||||
rows.value = data.countries || []
|
||||
pinCodes.value = pinData.codes || {}
|
||||
pinBridges.value = pinData.bridges || {}
|
||||
pinRows.value = pinData.pins || []
|
||||
|
||||
/* Same seven-slot shape the rest of this file already speaks, so narrowing the roster
|
||||
changes WHICH pins exist and nothing about how they are drawn, clicked or filtered. */
|
||||
const roster = (doxaData.rows || [])
|
||||
doxaTotal.value = roster.length
|
||||
pinRows.value = roster
|
||||
.filter((r) => r.mapped && r.lat != null && r.lng != null)
|
||||
.map((r) => [r.name, r.lat, r.lng, r.iso3, r.iso639_3 || '', r.population || 0, r.tier ?? 0])
|
||||
pinCount.value = pinRows.value.length
|
||||
doxaUnplaced.value = doxaTotal.value - pinCount.value
|
||||
|
||||
/* Countries Doxa actually works, and how many of their groups have nothing. A country
|
||||
outside this set is NOT "zero" — it is out of scope, and painting it at all was the
|
||||
misleading part: the United States holds three Doxa groups, so one without a resource
|
||||
read as 33% and turned the whole country amber. */
|
||||
const tally = {}
|
||||
for (const r of roster) {
|
||||
const t = (tally[r.iso3] ||= { groups: 0, none: 0, people: 0 })
|
||||
t.groups += 1
|
||||
t.people += r.population || 0
|
||||
if (r.mapped && r.tier === 0) t.none += 1
|
||||
}
|
||||
doxaByCountry.value = tally
|
||||
return adm0
|
||||
}
|
||||
|
||||
|
|
@ -785,8 +882,10 @@ function paint(map, adm0) {
|
|||
properties: {
|
||||
iso3: iso,
|
||||
band: d?.band || 'none',
|
||||
color: d ? featureColor(d) : 'rgba(0,0,0,0)',
|
||||
hasData: !!d,
|
||||
/* Keyed by ISO3, not by the old country row: scope now comes from Doxa's roster,
|
||||
and a country they do not work gets no fill rather than a "covered" colour. */
|
||||
color: featureColor(iso),
|
||||
hasData: !!doxaByCountry.value[iso],
|
||||
},
|
||||
geometry: f.geometry,
|
||||
}
|
||||
|
|
@ -995,9 +1094,9 @@ useShadowStyles(
|
|||
// of it rather than run under it. 46px = 32px button + 3px inset + clearance.
|
||||
'@media(max-width:480px){.gm-legend{width:auto;right:46px}}' +
|
||||
'.is-dark .gm-legend{background:rgba(19,32,25,.95);color:#e5e7eb}' +
|
||||
'.gm-title{font-weight:700;font-size:12.5px;margin-bottom:5px}' +
|
||||
'.gm-title{font-weight:700;font-size:15.5px;margin-bottom:5px}' +
|
||||
'.gm-keys{display:flex;flex-wrap:wrap;gap:5px 12px}' +
|
||||
'.gm-key{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}' +
|
||||
'.gm-key{display:flex;align-items:center;gap:5px;font-size:14px;white-space:nowrap}' +
|
||||
// This one label is a phrase, not a legend word, so it is allowed to wrap rather than
|
||||
// be clipped by the nowrap above.
|
||||
'.gm-key.wide{white-space:normal;text-align:left;line-height:1.25}' +
|
||||
|
|
@ -1007,17 +1106,18 @@ useShadowStyles(
|
|||
'.gm-pinkey{margin-top:7px;padding-top:6px;border-top:1px solid rgba(0,0,0,.12)}' +
|
||||
'.is-dark .gm-pinkey{border-top-color:rgba(255,255,255,.16)}' +
|
||||
'.gm-keys-btn{gap:4px 8px}' +
|
||||
'.gm-kbtn{border:1px solid transparent;background:transparent;color:inherit;font:inherit;font-size:11px;' +
|
||||
'.gm-kbtn{border:1px solid transparent;background:transparent;color:inherit;font:inherit;font-size:14px;' +
|
||||
'padding:2px 6px;border-radius:999px;cursor:pointer;min-height:24px;display:inline-flex;align-items:center;gap:5px}' +
|
||||
'.gm-kbtn:hover{background:#e9ebe6;border-color:#d1d4cd}' +
|
||||
'.gm-kbtn.on{background:#3b463d;border-color:#3b463d;color:#fff}' +
|
||||
'.gm-kbtn b{font-variant-numeric:tabular-nums;opacity:.75;font-weight:700}' +
|
||||
'.gm-filtered{margin-top:5px;font-size:10.5px;line-height:1.45;color:#3b463d}' +
|
||||
'.gm-filtered{margin-top:5px;font-size:13.5px;line-height:1.45;color:#3b463d}' +
|
||||
'.is-dark .gm-filtered{color:#e5e7eb}' +
|
||||
'.gm-kclear{border:1px solid #d1d4cd;background:#fff;color:#3b463d;font:600 10.5px inherit;' +
|
||||
'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-pinkey-h{font-size:14px;font-weight:600;margin-bottom:4px}' +
|
||||
'.gm-caveat{margin-top:5px;font-size:13.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
|
||||
|
|
@ -1035,29 +1135,29 @@ useShadowStyles(
|
|||
'@media(min-width:700px){.gm-sheet{left:auto;right:14px;bottom:14px;width:330px;border-radius:12px}}' +
|
||||
'.is-dark .gm-sheet{background:#132019;color:#e5e7eb}' +
|
||||
'.gm-sh-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}' +
|
||||
'.gm-sh-name{font-weight:800;font-size:15px}' +
|
||||
'.gm-sh-name{font-weight:800;font-size:18px}' +
|
||||
'.gm-x{margin-left:auto;border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
|
||||
'font:600 12px inherit;padding:6px 12px;border-radius:8px;cursor:pointer;min-height:34px}' +
|
||||
'.gm-stat{display:flex;justify-content:space-between;gap:12px;padding:4px 0;' +
|
||||
'border-bottom:1px solid rgba(0,0,0,.07);font-variant-numeric:tabular-nums}' +
|
||||
'.is-dark .gm-stat{border-bottom-color:rgba(255,255,255,.09)}' +
|
||||
'.gm-stat b{font-weight:700}' +
|
||||
'.gm-note{margin-top:9px;font-size:11px;line-height:1.5;opacity:.75}' +
|
||||
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:13px;opacity:.7}' +
|
||||
'.gm-note{margin-top:9px;font-size:14px;line-height:1.5;opacity:.75}' +
|
||||
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:16px;opacity:.7}' +
|
||||
// MODE TOGGLE — sits in the legend, because the legend is what it changes.
|
||||
'.gm-fold{width:100%;margin:0 0 6px;border:1px solid #d1d4cd;background:#f3f3f1;color:#3b463d;font:600 11px inherit;padding:6px 10px;border-radius:8px;cursor:pointer;min-height:32px}' +
|
||||
'.gm-fold:hover{background:#e9ebe6}' +
|
||||
'.gm-search{position:relative;margin-bottom:6px}' +
|
||||
'.gm-q{width:100%;box-sizing:border-box;border:1px solid #d1d4cd;border-radius:8px;background:#fff;color:#3b463d;font:12px inherit;padding:7px 26px 7px 9px;min-height:32px}' +
|
||||
'.gm-q:focus{outline:2px solid #92b195;outline-offset:1px}' +
|
||||
'.gm-qx{position:absolute;right:4px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#6d766e;font-size:16px;line-height:1;cursor:pointer;padding:4px 6px;min-height:24px}' +
|
||||
'.gm-qx{position:absolute;right:4px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#6d766e;font-size:19px;line-height:1;cursor:pointer;padding:4px 6px;min-height:24px}' +
|
||||
'.gm-qres{margin:-2px 0 7px;border:1px solid #d1d4cd;border-radius:8px;background:#fff;overflow:hidden}' +
|
||||
'.gm-qrow{display:flex;align-items:baseline;gap:6px;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:11px;color:#3b463d;padding:6px 8px;cursor:pointer;min-height:30px;border-bottom:1px solid #eceee9}' +
|
||||
'.gm-qrow{display:flex;align-items:baseline;gap:6px;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:14px;color:#3b463d;padding:6px 8px;cursor:pointer;min-height:30px;border-bottom:1px solid #eceee9}' +
|
||||
'.gm-qrow:last-child{border-bottom:0}' +
|
||||
'.gm-qrow:hover{background:#f3f3f1}' +
|
||||
'.gm-qn{font-weight:700}' +
|
||||
'.gm-ql{opacity:.7}' +
|
||||
'.gm-qnone,.gm-qmore{padding:6px 8px;font-size:10.5px;color:#6d766e}' +
|
||||
'.gm-qnone,.gm-qmore{padding:6px 8px;font-size:13.5px;color:#6d766e}' +
|
||||
'.gm-modes{display:flex;gap:6px;margin-bottom:6px}' +
|
||||
'.gm-mode{font:600 10.5px inherit;padding:4px 10px;border-radius:999px;cursor:pointer;' +
|
||||
'border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;min-height:26px}' +
|
||||
|
|
@ -1065,7 +1165,7 @@ useShadowStyles(
|
|||
'.is-dark .gm-mode{border-color:rgba(255,255,255,.22)}' +
|
||||
'.is-dark .gm-mode.on{background:#10b981;border-color:#10b981;color:#04241a}' +
|
||||
// RESOURCE LIST inside the sheet.
|
||||
'.gm-scope{margin:7px 0 0;font-size:11.5px;line-height:1.45;color:#3b463d}' +
|
||||
'.gm-scope{margin:7px 0 0;font-size:14.5px;line-height:1.45;color:#3b463d}' +
|
||||
'.gm-scope b{font-weight:700}' +
|
||||
'.is-dark .gm-scope{color:#e5e7eb}' +
|
||||
// Same frame as the sign-language note: a caveat, visually separate from the chips so it
|
||||
|
|
@ -1080,26 +1180,26 @@ useShadowStyles(
|
|||
'.gm-gtab.on{background:#92b195;border-color:#92b195;color:#fff}' +
|
||||
'.is-dark .gm-gtab{background:transparent;border-color:rgba(255,255,255,.22);color:#e5e7eb}' +
|
||||
'.is-dark .gm-gtab.on{background:#92b195;color:#10241a}' +
|
||||
'.gm-pg-rel{display:block;margin-top:2px;font-size:10px;color:#6d766e;font-style:italic}' +
|
||||
'.gm-pg-rel{display:block;margin-top:2px;font-size:13px;color:#6d766e;font-style:italic}' +
|
||||
'.is-dark .gm-pg-rel{color:#9fb3a4}' +
|
||||
'.gm-rel{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #92b195;' +
|
||||
'border-radius:6px;background:#fff;font-size:11.5px;line-height:1.5}' +
|
||||
'.gm-rel code{font-size:11px;background:#f3f3f1;padding:1px 4px;border-radius:3px}' +
|
||||
'border-radius:6px;background:#fff;font-size:14.5px;line-height:1.5}' +
|
||||
'.gm-rel code{font-size:14px;background:#f3f3f1;padding:1px 4px;border-radius:3px}' +
|
||||
'.is-dark .gm-rel{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-sign{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #6d766e;' +
|
||||
'border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:11.5px;line-height:1.5}' +
|
||||
'border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:14.5px;line-height:1.5}' +
|
||||
'.is-dark .gm-sign{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-pray{display:block;margin:8px 0 0;padding:9px 11px;border-radius:8px;text-decoration:none;' +
|
||||
'background:#3b463d;color:#fff;font:600 12.5px inherit;text-align:center}' +
|
||||
'.gm-pray:hover,.gm-pray:focus-visible{background:#2f382f}' +
|
||||
'.gm-pray-n{display:block;margin-top:2px;font-weight:500;font-size:11px;opacity:.85}' +
|
||||
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:12.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
|
||||
'.gm-pray-n{display:block;margin-top:2px;font-weight:500;font-size:14px;opacity:.85}' +
|
||||
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:15.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
|
||||
'.gm-tablink:hover{background:#e9ebe6}' +
|
||||
'.is-dark .gm-tablink{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-stat-alt span{opacity:.72}' +
|
||||
'.gm-sec{margin-top:12px}' +
|
||||
'.gm-sec-h{font-weight:800;font-size:12px;letter-spacing:.02em;margin-bottom:6px}' +
|
||||
'.gm-load{padding:10px 0;font-size:11.5px;opacity:.65}' +
|
||||
'.gm-sec-h{font-weight:800;font-size:15px;letter-spacing:.02em;margin-bottom:6px}' +
|
||||
'.gm-load{padding:10px 0;font-size:14.5px;opacity:.65}' +
|
||||
// .gm-pg is now the SHELL only. The resource rows are a <div> shell (so the chips
|
||||
// inside can be real anchors) while the no-resource rows are still a <button>; both
|
||||
// must look identical, so the shell carries the box and .gm-pg-btn/.gm-pg-open carry
|
||||
|
|
@ -1114,8 +1214,8 @@ useShadowStyles(
|
|||
// look, so the row still reads as one tap-target for the name + language line.
|
||||
'.gm-pg-open{display:block;width:100%;text-align:left;padding:0;margin:0;border:0;' +
|
||||
'background:transparent;color:inherit;font:inherit;cursor:pointer}' +
|
||||
'.gm-pg-n{display:block;font-weight:700;font-size:12.5px}' +
|
||||
'.gm-pg-l{display:block;font-size:11px;opacity:.75;margin-top:1px}' +
|
||||
'.gm-pg-n{display:block;font-weight:700;font-size:15.5px}' +
|
||||
'.gm-pg-l{display:block;font-size:14px;opacity:.75;margin-top:1px}' +
|
||||
'.gm-code{display:inline-block;font:600 10px ui-monospace,SFMono-Regular,Menlo,monospace;' +
|
||||
'padding:1px 5px;border-radius:4px;background:rgba(0,0,0,.09);margin-left:5px;vertical-align:1px}' +
|
||||
'.is-dark .gm-code{background:rgba(255,255,255,.12)}' +
|
||||
|
|
@ -1124,7 +1224,7 @@ useShadowStyles(
|
|||
// entry can be "Aligned Bible (translationCore-Create-BCS) - Door43 - Portions of both",
|
||||
// which simply ran off the edge of a 330px sheet and could not be read at all. Long chips
|
||||
// wrap instead; max-width keeps them inside the panel either way.
|
||||
'.gm-chip{font-size:10px;padding:2px 7px;border-radius:999px;white-space:nowrap;max-width:100%;' +
|
||||
'.gm-chip{font-size:13px;padding:2px 7px;border-radius:999px;white-space:nowrap;max-width:100%;' +
|
||||
'background:rgba(0,0,0,.06);text-decoration:none;color:inherit;border:1px solid transparent}' +
|
||||
'.gm-chip.wrap{white-space:normal;overflow-wrap:anywhere;line-height:1.35}' +
|
||||
'.is-dark .gm-chip{background:rgba(255,255,255,.08)}' +
|
||||
|
|
@ -1140,28 +1240,25 @@ useShadowStyles(
|
|||
// "unknown") must keep looking inert, because it is.
|
||||
'a.gm-chip{cursor:pointer}' +
|
||||
'a.gm-chip:hover,a.gm-chip:focus-visible{text-decoration:underline}' +
|
||||
'.gm-none{font-size:11px;opacity:.7}' +
|
||||
'.gm-none{font-size:14px;opacity:.7}' +
|
||||
'.gm-back{border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
|
||||
'font:600 11px inherit;padding:5px 11px;border-radius:8px;cursor:pointer;min-height:30px}' +
|
||||
'.is-dark .gm-back{border-color:rgba(255,255,255,.22)}' +
|
||||
'.gm-more{font-size:11px;opacity:.66;margin-top:2px}',
|
||||
'.gm-more{font-size:14px;opacity:.66;margin-top:2px}',
|
||||
'doxa-gospel-map',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- dir="ltr" ON THIS BUNDLE'S OWN ROOT, and it comes off the day these strings are
|
||||
translated. createAppI18n() sets document.documentElement.dir="rtl" for Arabic, which
|
||||
is right for a translated app and wrong for this one: every string in these five
|
||||
profiles is hard-coded English, so the flip produced English text in an RTL box.
|
||||
Measured 2026-09-25 at ?lang=ar — identical word counts to ?lang=en and zero non-Latin
|
||||
characters, so nothing was gained, and what it cost was visible: the legend title
|
||||
clipped its first letter against the left edge, "30-59%" rendered as "59%-30", the
|
||||
colour swatches sat on the far side of their labels, sentence-ending periods jumped to
|
||||
the START of lines, and the zoom controls landed on top of the search box.
|
||||
Not merely untranslated — scrambled. Until the strings are translated, LTR is the
|
||||
honest layout for English content, and the host page's own dir is left untouched. -->
|
||||
<div class="gm" dir="ltr" :class="{ 'is-dark': isDark, 'has-sheet': !!(selected || showGapList) }">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Shared control rail — MapToolbar self-positions top-right. Zoom/Location/
|
||||
|
|
@ -1181,12 +1278,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">×</button>
|
||||
<button v-if="q" type="button" class="gm-qx" @click="q = ''" :aria-label="t('gospel.search.clear')">×</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>
|
||||
|
|
@ -1198,8 +1295,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'">
|
||||
|
|
@ -1212,18 +1309,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
|
||||
|
|
@ -1232,19 +1337,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
|
||||
|
|
@ -1253,7 +1358,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>
|
||||
|
|
@ -1268,15 +1373,23 @@ 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
|
||||
by something the dots on top of them contradicted. -->
|
||||
<b>A dot and the country under it mean different things.</b> The country is an AVERAGE
|
||||
across its groups; a dot is that ONE group's own language. So a red dot can sit on a green
|
||||
country — that is the case worth finding, not a rendering fault. Both are counted from the
|
||||
same index, so the colour and the dots can no longer disagree.
|
||||
<b>A dot and the country under it mean different things.</b> A dot is ONE of Doxa's
|
||||
people groups and its own language. A country is a COUNT of those groups — how many of
|
||||
Doxa's are there with nothing found — never a percentage: 106 of these 175 countries hold
|
||||
one or two groups, and a share over a denominator of two says nothing. A country Doxa does
|
||||
not work is left UNCOLOURED rather than drawn as covered, because this dataset has no
|
||||
finding about it either way.
|
||||
<br>“None found” = not in the pools checked (eBible, PNG.Bible, JESUS Film, Door43,
|
||||
Faith Comes By Hearing, Scripture Earth and Global Recordings Network). GRN is reached
|
||||
through their 5fish site, and that sweep is now <b>finished for every group in the gap</b>
|
||||
|
|
@ -1288,7 +1401,7 @@ useShadowStyles(
|
|||
<template v-if="mode === 'coverage'">
|
||||
A “found” colour is equally bounded: it counts what these pools list, not everything that exists.
|
||||
</template>
|
||||
<br>Either way the colour is only a country average — tap a country to see the actual resources, group by group.
|
||||
<br>Tap a country to see the actual resources, group by group.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1304,9 +1417,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'">
|
||||
|
|
@ -1383,15 +1496,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
|
||||
|
|
@ -1447,25 +1560,25 @@ 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"
|
||||
:title="vtitle(selectedGroup.scripture_url)">
|
||||
Scripture · {{ vchip(selectedGroup.scripture_url).label }}
|
||||
{{ t('gospel.res.scripture') }} · {{ t(vchip(selectedGroup.scripture_url).labelKey) }}
|
||||
</a>
|
||||
<span v-else class="gm-chip meh">Scripture · none found</span>
|
||||
<span v-else class="gm-chip meh">{{ t('gospel.res.scripture') }} · {{ t('gospel.state.noneFound') }}</span>
|
||||
|
||||
<!-- The title says what the evidence IS. The JESUS Film Project's own API
|
||||
confirms a feature film in this language; the watch URL opens their site
|
||||
|
|
@ -1477,9 +1590,9 @@ 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.'">
|
||||
{{ selectedGroup.film_title || 'Film' }} · {{ vchip(selectedGroup.film_url).label }}
|
||||
{{ selectedGroup.film_title || t('gospel.res.film') }} · {{ t(vchip(selectedGroup.film_url).labelKey) }}
|
||||
</a>
|
||||
<span v-else class="gm-chip meh">Film · none found</span>
|
||||
<span v-else class="gm-chip meh">{{ t('gospel.res.film') }} · {{ t('gospel.state.noneFound') }}</span>
|
||||
|
||||
<!-- AUDIO SCRIPTURE, from Faith Comes By Hearing. This chip read "not searched
|
||||
(GRN)" on every group for as long as this map existed. GRN is now searched too,
|
||||
|
|
@ -1501,7 +1614,7 @@ useShadowStyles(
|
|||
FCBH does hold something for them it is text, and the link above still shows. -->
|
||||
<span v-else-if="!selectedGroup.is_sign" class="gm-chip meh"
|
||||
title="Faith Comes By Hearing publishes no audio or text Scripture in this language. Global Recordings Network is searched separately and appears as its own Gospel audio chip when they hold something.">
|
||||
Audio · none found at FCBH
|
||||
{{ t('gospel.res.audio') }} · {{ t('gospel.state.noneAtFcbh') }}
|
||||
</span>
|
||||
|
||||
<!-- Bible VIDEO, kept visibly apart from Film. Film here means the JESUS Film;
|
||||
|
|
@ -1521,7 +1634,7 @@ useShadowStyles(
|
|||
{{ /^Open Bible Stories/.test(d.s) ? d.s.replace('Open Bible Stories', 'Bible Stories') : d.s }} · Door43<template v-if="d.sc"> · {{ d.sc }}</template>
|
||||
</a>
|
||||
<span v-if="!selectedGroup.door43.length" class="gm-chip meh">
|
||||
Open Bible Stories · none found
|
||||
{{ t('gospel.res.bibleStories') }} · {{ t('gospel.state.noneFound') }}
|
||||
</span>
|
||||
|
||||
<!-- A GOSPEL PRESENTATION — the short translated thing you actually open with
|
||||
|
|
@ -1540,7 +1653,7 @@ useShadowStyles(
|
|||
<a v-if="selectedGroup.gospel_audio" class="gm-chip ok wrap"
|
||||
:href="selectedGroup.gospel_audio.u" target="_blank" rel="noopener noreferrer"
|
||||
title="Global Recordings Network publishes gospel audio in this language — usually Words of Life, gospel messages and Bible stories with songs. It is not a translation of the Bible.">
|
||||
Gospel audio · GRN
|
||||
{{ t('gospel.res.gospelAudio') }} · GRN
|
||||
</a>
|
||||
<!-- THE ABSENT CASE, which was missing while five other pools had one. For a group
|
||||
in the gap this sheet listed "none found" for Scripture, film, FCBH audio,
|
||||
|
|
@ -1554,7 +1667,7 @@ useShadowStyles(
|
|||
<span v-else-if="!selectedGroup.is_sign && poolSearched(selectedGroup, 'grn')"
|
||||
class="gm-chip meh"
|
||||
title="Global Recordings Network was asked for this language through their 5fish site and holds nothing in it. Shown only when they were actually asked — the sweep ran against the gap first, so a language that already has resources may not have been.">
|
||||
Gospel audio · none found at GRN
|
||||
{{ t('gospel.res.gospelAudio') }} · {{ t('gospel.state.noneAtGrn') }}
|
||||
</span>
|
||||
|
||||
<!-- SCRIPTURE EARTH — SIL and its translation partners, reaching small languages
|
||||
|
|
@ -1565,14 +1678,14 @@ useShadowStyles(
|
|||
<a v-if="selectedGroup.scripture_earth" class="gm-chip ok wrap"
|
||||
:href="selectedGroup.scripture_earth.u" target="_blank" rel="noopener noreferrer"
|
||||
title="Scripture Earth (SIL International and its translation partners) publishes Scripture in this language. The listing shows every file they hold for it.">
|
||||
Scripture Earth · {{ seKinds(selectedGroup.scripture_earth.k) }}
|
||||
{{ t('gospel.res.scriptureEarth') }} · {{ seKinds(selectedGroup.scripture_earth.k) }}
|
||||
</a>
|
||||
<!-- Shown for a sign language too, unlike the audio chips: Scripture Earth
|
||||
publishes text, PDFs and apps, so its absence is a real answer for a Deaf
|
||||
group rather than noise about a medium they cannot use. -->
|
||||
<span v-else-if="poolSearched(selectedGroup, 'scriptureearth')" class="gm-chip meh"
|
||||
title="Scripture Earth (SIL International and its translation partners) holds nothing under this language code. That sweep covered every code this map carries, so it is a searched-and-empty result.">
|
||||
Scripture Earth · none found
|
||||
{{ t('gospel.res.scriptureEarth') }} · {{ t('gospel.state.noneFound') }}
|
||||
</span>
|
||||
|
||||
<!-- Zume is DISCIPLESHIP TRAINING, not Scripture and not film, so it sits here
|
||||
|
|
@ -1582,7 +1695,7 @@ useShadowStyles(
|
|||
<a v-if="selectedGroup.zume_url" class="gm-chip ok"
|
||||
:href="selectedGroup.zume_url" target="_blank" rel="noopener noreferrer"
|
||||
title="Zume Training publishes its discipleship course in this language — free, no account needed to read it.">
|
||||
Discipleship training · Zume
|
||||
{{ t('gospel.res.training') }} · Zume
|
||||
</a>
|
||||
<!-- APPROXIMATE, and it says so on its face. Zume publishes a written course
|
||||
in this language but does not state which variety, and this group speaks
|
||||
|
|
@ -1592,11 +1705,11 @@ useShadowStyles(
|
|||
<a v-else-if="selectedGroup.zume_approx" class="gm-chip approx"
|
||||
:href="selectedGroup.zume_approx.u" target="_blank" rel="noopener noreferrer"
|
||||
:title="selectedGroup.zume_approx.n + '. The course is written in one standard script, so it is likely to be readable — but it was not published against this ISO code and is shown as an approximate match, not a confirmed one.'">
|
||||
Discipleship training · Zume, in {{ selectedGroup.zume_approx.l }} <em>(variety not stated)</em>
|
||||
{{ t('gospel.res.training') }} · Zume, {{ t('gospel.state.inLanguage') }} {{ selectedGroup.zume_approx.l }} <em>{{ t('gospel.state.varietyNotStated') }}</em>
|
||||
</a>
|
||||
<span v-else class="gm-chip meh"
|
||||
title="Zume Training publishes in 54 languages and this is not one of them. That is a gap in one training platform, not a statement about every training resource.">
|
||||
Discipleship training · not in Zume's 54
|
||||
{{ t('gospel.res.training') }} · {{ t('gospel.state.notInZume') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- AN IN-COUNTRY BRIDGE, and only for a group with nothing of their own. Worded
|
||||
|
|
@ -1605,7 +1718,7 @@ useShadowStyles(
|
|||
untouched. A bridge is a way in, not a translation. -->
|
||||
<div v-if="selectedGroup.bridge && !selectedGroup.scripture_url && !selectedGroup.film_url
|
||||
&& !selectedGroup.audio && !selectedGroup.gospel_audio && !selectedGroup.scripture_earth"
|
||||
class="gm-sign" style="border-left-color:#92b195">
|
||||
class="gm-sign" style="border-left-color:#92b195" dir="ltr">
|
||||
<b>Not their language — but spoken around them.</b>
|
||||
<br>Nothing was found in {{ selectedGroup.language || 'their language' }}. In this
|
||||
country, <b>{{ selectedGroup.bridge.l }}</b> is spoken by
|
||||
|
|
@ -1670,7 +1783,7 @@ useShadowStyles(
|
|||
<a v-if="selectedPray" class="gm-pray" :href="selectedPray.u"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
:title="'Open the prayer page for ' + selectedPray.n + ' on doxa.life.'">
|
||||
Pray for {{ selectedPray.n }}
|
||||
{{ t('gospel.sheet.pray') }} {{ selectedPray.n }}
|
||||
<span v-if="selectedPray.praying" class="gm-pray-n">{{ fmt(selectedPray.praying) }} already praying</span>
|
||||
</a>
|
||||
|
||||
|
|
@ -1678,15 +1791,15 @@ useShadowStyles(
|
|||
the promise is the same; only the destination differs. -->
|
||||
<button v-if="selectedGroup.iso639_3 && composed" type="button" class="gm-tablink"
|
||||
@click="filterComposedTables({ iso: selectedGroup.iso639_3, label: selectedGroup.language || '' })">
|
||||
Every resource in {{ selectedGroup.language }} ↓
|
||||
{{ t('gospel.sheet.everyResource') }} {{ selectedGroup.language }} ↓
|
||||
</button>
|
||||
<a v-else-if="selectedGroup.iso639_3" class="gm-tablink"
|
||||
:href="tableLink({ iso: selectedGroup.iso639_3 || '', label: selectedGroup.language || '' })">
|
||||
Every resource in {{ selectedGroup.language }} →
|
||||
{{ t('gospel.sheet.everyResource') }} {{ selectedGroup.language }} →
|
||||
</a>
|
||||
|
||||
<div class="gm-sec" v-if="sameLanguage.length">
|
||||
<div class="gm-sec-h">Also speak {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
|
||||
<div class="gm-sec-h">{{ t('gospel.sheet.alsoSpeak') }} {{ selectedGroup.language }} ({{ sameLanguage.length }})</div>
|
||||
<button v-for="g in sameLanguage.slice(0, 12)" :key="g.people_group + g.countries"
|
||||
type="button" class="gm-pg gm-pg-btn" @click="openGroup(g)">
|
||||
<span class="gm-pg-n">{{ g.people_group }}</span>
|
||||
|
|
@ -1697,7 +1810,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:
|
||||
|
|
@ -1711,18 +1824,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.
|
||||
|
|
@ -1731,9 +1844,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 & 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
|
||||
|
|
@ -1782,12 +1895,12 @@ useShadowStyles(
|
|||
<a v-if="g.scripture_url" class="gm-chip" :class="vchip(g.scripture_url).cls"
|
||||
:href="g.scripture_url" target="_blank" rel="noopener noreferrer"
|
||||
:title="vtitle(g.scripture_url)">
|
||||
Scripture · {{ vchip(g.scripture_url).label }}
|
||||
{{ t('gospel.res.scripture') }} · {{ t(vchip(g.scripture_url).labelKey) }}
|
||||
</a>
|
||||
<a v-if="g.film_url" class="gm-chip" :class="vchip(g.film_url).cls"
|
||||
:href="g.film_url" target="_blank" rel="noopener noreferrer"
|
||||
:title="vtitle(g.film_url)">
|
||||
Film · {{ vchip(g.film_url).label }}
|
||||
{{ t('gospel.res.film') }} · {{ t(vchip(g.film_url).labelKey) }}
|
||||
</a>
|
||||
<!-- The other three media. A country view that shows only Scripture and film
|
||||
under-reports what is actually reachable there, and the pin sheet for the
|
||||
|
|
@ -1836,7 +1949,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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* build-doxa-gospel-access.mjs — the table this whole map is for.
|
||||
*
|
||||
* ═══ THE QUESTION, IN THE DRIVER'S WORDS ════════════════════════════════════════════════
|
||||
* "Which of the Doxa people groups have resources and which don't — that is the key question
|
||||
* the map has to answer." And the deliverable is not a picture: they want to export it, hand
|
||||
* it to a team, and turn it into QR codes, pamphlets and posters on the noticeboards of the
|
||||
* key towns. So this is ONE ROW PER DOXA PEOPLE GROUP, every resource it has, every URL
|
||||
* openable, and an access status a person can sort on.
|
||||
*
|
||||
* ═══ WHY IT IS NOT THE LINKS TABLE ══════════════════════════════════════════════════════
|
||||
* gospel-resource-links is keyed by RESOURCE across 4,231 languages — what exists, anywhere.
|
||||
* This is keyed by the PEOPLE GROUP Doxa actually carries, and it is scoped to their 2,107.
|
||||
* A mobiliser working Doxa's list cannot use a table that is 80% other people's groups, and
|
||||
* a team planning a translation cannot use a table that hides the ones with nothing.
|
||||
*
|
||||
* ═══ THE ACCESS LADDER, AND WHY IT IS NOT A SCORE ═══════════════════════════════════════
|
||||
* Every rung says what a person can DO, because that is what decides what you send:
|
||||
* read and watch text Scripture AND film in their own language
|
||||
* read text, no film
|
||||
* watch or hear film, audio, Bible video or Words of Life — but NO text
|
||||
* stories only Bible stories or a gospel presentation, no Scripture and no film
|
||||
* nothing found no pool this build can reach holds anything
|
||||
* "Watch or hear, cannot read" is called out because it is the translation brief: those
|
||||
* groups can already receive the gospel and cannot yet study it, and the first believers
|
||||
* among them cannot be trained from a text that does not exist.
|
||||
*
|
||||
* NOT PLACED IS NOT ABSENT. 285 of the roster have no coordinates in this build, so they
|
||||
* are invisible on the map. They are IN this table, with placed:false, because the roster is
|
||||
* Doxa's and a list that quietly loses 14% of it would be its own kind of lie.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { writeSnapshot } from './_snapshot-guard.mjs'
|
||||
import { resourceRows } from '../gospel-resource-rows.js'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '../../..')
|
||||
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
|
||||
|
||||
const doxa = T('doxa-people-groups.json')
|
||||
const { codes } = T('people-group-pins.json')
|
||||
|
||||
const TYPE_ORDER = ['Scripture', 'Film', 'Audio Scripture', 'Bible video', 'Bible Stories', 'Gospel presentation', 'Gospel audio', 'Discipleship training']
|
||||
const first = (rows, type) => rows.find((r) => r.type === type) || null
|
||||
|
||||
const rows = doxa.rows.map((g) => {
|
||||
const c = g.iso639_3 ? codes[g.iso639_3] : null
|
||||
const res = c ? resourceRows(c) : []
|
||||
const byType = {}
|
||||
for (const t of TYPE_ORDER) byType[t] = res.filter((r) => r.type === t)
|
||||
|
||||
const hasText = byType.Scripture.length > 0
|
||||
const hasFilm = byType.Film.length > 0
|
||||
const canHear = byType['Audio Scripture'].length > 0 || byType['Bible video'].length > 0 || byType['Gospel audio'].length > 0 || hasFilm
|
||||
const hasStories = byType['Bible Stories'].length > 0 || byType['Gospel presentation'].length > 0
|
||||
|
||||
/* NO LANGUAGE CODE IS NOT "NOTHING FOUND". The first run of this table collapsed the two
|
||||
and reported 566 groups with nothing, when 281 had been searched and come back empty and
|
||||
285 had never been searchable at all — no ISO 639-3, so there is no key to look them up
|
||||
by in any pool. Reporting those as "nothing found" would have told a mobiliser that 285
|
||||
peoples have no gospel in their language when the truth is that nobody has been able to
|
||||
ask. They are their own rung, and it is an identification problem before it is a
|
||||
translation one. */
|
||||
let access = g.iso639_3 ? 'nothing found' : 'no language code — not searched'
|
||||
if (hasText && hasFilm) access = 'read and watch'
|
||||
else if (hasText) access = 'read'
|
||||
else if (canHear) access = 'watch or hear, cannot read'
|
||||
else if (hasStories) access = 'stories only'
|
||||
|
||||
const link = (t) => (first(res, t)?.url || '')
|
||||
const name = (t) => (first(res, t)?.title || '')
|
||||
|
||||
return {
|
||||
people_group: g.name,
|
||||
country: g.country,
|
||||
iso3: g.iso3,
|
||||
region: g.region,
|
||||
language: g.language || '',
|
||||
iso639_3: g.iso639_3 || '',
|
||||
population: g.population,
|
||||
religion: g.religion,
|
||||
access,
|
||||
resources: res.length,
|
||||
scripture: name('Scripture'),
|
||||
scripture_url: link('Scripture'),
|
||||
film_url: link('Film'),
|
||||
audio_url: link('Audio Scripture'),
|
||||
bible_video_url: link('Bible video'),
|
||||
stories_url: link('Bible Stories'),
|
||||
gospel_audio_url: link('Gospel audio'),
|
||||
training_url: link('Discipleship training'),
|
||||
pray_url: g.pray_url,
|
||||
people_praying: g.people_praying,
|
||||
placed: g.mapped,
|
||||
}
|
||||
})
|
||||
|
||||
const tally = {}
|
||||
for (const r of rows) tally[r.access] = (tally[r.access] || 0) + 1
|
||||
const noCode = rows.filter((r) => !r.iso639_3).length
|
||||
|
||||
writeSnapshot(
|
||||
resolve(REPO, 'data/tables/doxa-gospel-access.json'),
|
||||
`${JSON.stringify({
|
||||
meta: {
|
||||
title: 'Doxa people groups — what the gospel is available in, and what is missing',
|
||||
grain: 'one row per people group on Doxa\'s roster',
|
||||
source: 'pray.doxa.life roster joined to the resource index by ISO 639-3',
|
||||
generated: new Date().toISOString().slice(0, 10),
|
||||
groups: rows.length,
|
||||
by_access: tally,
|
||||
without_a_language_code: noCode,
|
||||
definition:
|
||||
'Access says what a person in this group can DO in their own language. "Nothing ' +
|
||||
'found" means no pool this build can reach holds anything for their language — ' +
|
||||
'never a claim that nothing exists. "Watch or hear, cannot read" is the translation ' +
|
||||
'brief: they can receive the gospel and cannot yet study it.',
|
||||
},
|
||||
columns: [
|
||||
{ title: 'People group', field: 'people_group', type: 'string' },
|
||||
{ title: 'Country', field: 'country', type: 'string' },
|
||||
{ title: 'Region', field: 'region', type: 'string' },
|
||||
{ title: 'Language', field: 'language', type: 'string' },
|
||||
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
|
||||
{ title: 'Population', field: 'population', type: 'number' },
|
||||
{ title: 'Religion', field: 'religion', type: 'string' },
|
||||
{ title: 'Gospel access', field: 'access', type: 'string' },
|
||||
{ title: 'Resources', field: 'resources', type: 'number' },
|
||||
{ title: 'Scripture', field: 'scripture', type: 'string' },
|
||||
{ title: 'Scripture link', field: 'scripture_url', type: 'url' },
|
||||
{ title: 'Film link', field: 'film_url', type: 'url' },
|
||||
{ title: 'Audio link', field: 'audio_url', type: 'url' },
|
||||
{ title: 'Bible video', field: 'bible_video_url', type: 'url' },
|
||||
{ title: 'Bible stories', field: 'stories_url', type: 'url' },
|
||||
{ title: 'Gospel audio', field: 'gospel_audio_url', type: 'url' },
|
||||
{ title: 'Training', field: 'training_url', type: 'url' },
|
||||
{ title: 'Pray', field: 'pray_url', type: 'url' },
|
||||
{ title: 'People praying', field: 'people_praying', type: 'number' },
|
||||
{ title: 'On the map', field: 'placed', type: 'string' },
|
||||
],
|
||||
rows,
|
||||
}, null, 1)}\n`,
|
||||
(j) => (j.rows || []).length,
|
||||
'Doxa gospel-access row(s)',
|
||||
)
|
||||
console.log(`doxa-gospel-access: ${rows.length} group(s)`)
|
||||
for (const [k, v] of Object.entries(tally).sort((a, b) => b[1] - a[1])) console.log(` ${String(v).padStart(5)} ${k}`)
|
||||
console.log(` ${noCode} row(s) carry no language code, so no pool can be searched for them`)
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* fetch-doxa-people-groups.mjs — the 2,107 people groups DOXA actually prays for.
|
||||
*
|
||||
* ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════════════════
|
||||
* This map was built on the full 12,379-pin people-groups dataset, and the Driver's reading
|
||||
* of it was blunt and correct: it is MISLEADING for the job it is meant to do. Doxa does not
|
||||
* work 12,379 groups. They work the ~2,100 on pray.doxa.life, and a map that shows every
|
||||
* group on earth buries the ones they can actually reach — and paints countries by a gap
|
||||
* that is not theirs.
|
||||
*
|
||||
* ═══ THE MEASUREMENT THAT SETTLES THE COLOUR ARGUMENT ═══════════════════════════════════
|
||||
* Measured 2026-09-25 against the live API:
|
||||
* 2,107 Doxa people groups across 175 countries
|
||||
* India 855 · China 154 · Sudan 105 · Brazil 103 · Laos 95
|
||||
* BUT 106 of those 175 countries hold only ONE OR TWO Doxa groups
|
||||
* and the United States holds THREE — Coquille, Pequots, Umpqua
|
||||
* The old choropleth paints a country by the PERCENTAGE of its groups with nothing found.
|
||||
* Over a denominator of three, one group without a resource is 33% and the whole United
|
||||
* States turns amber. That is the "mainly yellow map" complaint, and it is not a palette
|
||||
* problem underneath — it is a statistic that means nothing at n=3 being drawn as if it
|
||||
* meant something. A prettier ramp over the same arithmetic would be a prettier lie.
|
||||
*
|
||||
* ═══ WHAT THIS DOES AND DOES NOT KEEP ═══════════════════════════════════════════════════
|
||||
* Nothing found for the other 10,000 groups is thrown away — every index, every verified
|
||||
* link and every sweep result stays exactly where it is in data/tables/. This file adds the
|
||||
* DOXA ROSTER as its own table so the map can be narrowed to it without losing the research
|
||||
* behind it. The wider tables remain the record; the map stops being the place they are
|
||||
* dumped.
|
||||
*
|
||||
* ═══ THE JOIN, AND WHAT IT COSTS ════════════════════════════════════════════════════════
|
||||
* The API carries no coordinates and no ISO 639-3 code, so both come from the pin index by
|
||||
* (name, country) — the same key scripts/fetch-pray-index.mjs already uses, and the one join
|
||||
* on this map that is deliberately not by language, because you pray for a people in a
|
||||
* place. Measured: 1,724 of 2,107 match a pin and EVERY ONE of those carries a language
|
||||
* code. 383 do not match and are recorded with `mapped: false` rather than dropped — they
|
||||
* are Doxa's groups whether or not this build can place them, and a roster that silently
|
||||
* loses 18% of them would be its own kind of lie.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { writeSnapshot } from './_snapshot-guard.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '../../..')
|
||||
const PINS = resolve(REPO, 'data/tables/people-group-pins.json')
|
||||
const OUT = resolve(REPO, 'data/tables/doxa-people-groups.json')
|
||||
const API = 'https://pray.doxa.life/api/people-groups/list'
|
||||
const SITE = 'https://pray.doxa.life'
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
const res = await fetch(API, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(90000) })
|
||||
if (!res.ok) {
|
||||
console.error(`DOXA-PG: ABORT — the roster API answered HTTP ${res.status}. Refusing to write a partial roster.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const { posts } = await res.json()
|
||||
if (!Array.isArray(posts) || posts.length < 1000) {
|
||||
console.error(`DOXA-PG: ABORT — the roster came back with ${posts?.length ?? 0} entries. That is not the roster.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { codes, pins } = JSON.parse(readFileSync(PINS, 'utf8'))
|
||||
/**
|
||||
* ═══ THE NAME JOIN, AND THE ONE VARIATION IT IS SAFE TO ABSORB ══════════════════════════
|
||||
*
|
||||
* An exact name match left 383 of Doxa's groups unplaced — 18% of their roster invisible on
|
||||
* the map and absent from every count. Reading the misses rather than guessing at them, one
|
||||
* class is a spelling convention and nothing more: the roster writes the qualifier FIRST and
|
||||
* the pin dataset writes it LAST, around a comma.
|
||||
*
|
||||
* Acre Arara <-> Arara, Acre
|
||||
* Ashkuni Nuristani <-> Nuristani Ashkuni
|
||||
* Banka Samogho <-> Samogho, Banka
|
||||
*
|
||||
* Same words, same country, different order. Matching on the SORTED TOKEN SET absorbs
|
||||
* exactly that and nothing looser. Measured: 94 recovered this way plus 4 that only needed
|
||||
* diacritics folded, and ZERO ambiguous — every one resolved to a single pin inside its own
|
||||
* country.
|
||||
*
|
||||
* WHAT IT REFUSES, and why that matters more than what it accepts. Fuzzy matching would
|
||||
* "recover" far more and would be wrong. Doxa lists Akha in Laos; the pins carry Akha Jipea,
|
||||
* Akha Jaweuy and Akha Piso — a parent against three sub-varieties, and picking one would
|
||||
* invent a language for a people. Doxa lists Akeu in China; the nearest pin is Ake, a
|
||||
* different language. So: same country, identical token set, and UNIQUE. Two candidates is a
|
||||
* refusal, not a coin toss. 285 have no candidate at all and stay unplaced, which is the
|
||||
* honest state — they need a source this build does not have, not a cleverer string match.
|
||||
*/
|
||||
const fold = (n) => String(n || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
const tokenSet = (n) => fold(n).split(' ').filter(Boolean).sort().join(' ')
|
||||
const key = (n, c) => `${fold(n)}|${String(c || '').toUpperCase()}`
|
||||
const tkey = (n, c) => `${tokenSet(n)}|${String(c || '').toUpperCase()}`
|
||||
const pinBy = new Map()
|
||||
const pinByTokens = new Map()
|
||||
for (let i = 0; i < pins.length; i++) {
|
||||
const k = key(pins[i][0], pins[i][3])
|
||||
if (!pinBy.has(k)) pinBy.set(k, [])
|
||||
pinBy.get(k).push(i)
|
||||
const t = tkey(pins[i][0], pins[i][3])
|
||||
if (!pinByTokens.has(t)) pinByTokens.set(t, [])
|
||||
pinByTokens.get(t).push(i)
|
||||
}
|
||||
|
||||
const val = (f) => (f && typeof f === 'object' ? f.value : f) || ''
|
||||
const lab = (f) => (f && typeof f === 'object' ? f.label : f) || ''
|
||||
|
||||
let mapped = 0, ambiguous = 0, unmapped = 0, reordered = 0
|
||||
const rows = posts.map((q) => {
|
||||
const iso3 = val(q.country_code)
|
||||
let how = 'exact'
|
||||
let hit = pinBy.get(key(q.name, iso3))
|
||||
if (!hit) {
|
||||
const t = pinByTokens.get(tkey(q.name, iso3))
|
||||
/* Unique or nothing. A second candidate means the roster name and the pin name are not
|
||||
the same people, and there is no tie to break that would not be a guess. */
|
||||
if (t && t.length === 1) { hit = t; how = 'reordered'; reordered++ }
|
||||
else if (t && t.length > 1) ambiguous++
|
||||
} else if (hit.length > 1) ambiguous++
|
||||
const p = hit ? pins[hit[0]] : null
|
||||
if (p) mapped++; else { unmapped++; how = '' }
|
||||
const code = p ? p[4] || '' : ''
|
||||
const c = code ? codes[code] : null
|
||||
return {
|
||||
name: q.name,
|
||||
slug: q.slug,
|
||||
pray_url: `${SITE}/${q.slug}?source=doxalife`,
|
||||
iso3,
|
||||
country: lab(q.country_code),
|
||||
region: lab(q.wagf_region),
|
||||
block: lab(q.wagf_block),
|
||||
rop1: lab(q.rop1),
|
||||
religion: lab(q.religion),
|
||||
population: Number(q.population) || 0,
|
||||
people_praying: Number(q.people_praying) || 0,
|
||||
people_committed: Number(q.people_committed) || 0,
|
||||
adopted_by_churches: Number(q.adopted_by_churches) || 0,
|
||||
/* From the pin index, by (name, country). Empty where this build cannot place them. */
|
||||
mapped: !!p,
|
||||
/* How this row found its pin: exact name, or the same words in the other order. Empty
|
||||
when it did not. Recorded so a reader can audit the join instead of trusting it. */
|
||||
matched_by: how,
|
||||
lat: p ? p[1] : null,
|
||||
lng: p ? p[2] : null,
|
||||
iso639_3: code,
|
||||
language: c ? c.l || '' : '',
|
||||
tier: p ? p[6] : null,
|
||||
}
|
||||
})
|
||||
|
||||
const byCountry = {}
|
||||
for (const r of rows) byCountry[r.iso3] = (byCountry[r.iso3] || 0) + 1
|
||||
const thin = Object.values(byCountry).filter((n) => n <= 2).length
|
||||
|
||||
writeSnapshot(
|
||||
OUT,
|
||||
`${JSON.stringify({
|
||||
_note:
|
||||
'The people groups DOXA prays for, from pray.doxa.life. THIS is what the gospel map ' +
|
||||
'draws — not the full 12,379-group dataset, which stays in people-group-pins.json as ' +
|
||||
'the research record. mapped:false means the roster carries them but this build has no ' +
|
||||
'coordinates for them yet; they are kept, never dropped.',
|
||||
source: API,
|
||||
generated: new Date().toISOString().slice(0, 10),
|
||||
counts: {
|
||||
groups: rows.length,
|
||||
countries: Object.keys(byCountry).length,
|
||||
mapped,
|
||||
unmapped,
|
||||
ambiguousNameCountryKeys: ambiguous,
|
||||
matchedByReorder: reordered,
|
||||
countriesWithTwoOrFewer: thin,
|
||||
},
|
||||
rows,
|
||||
}, null, 1)}\n`,
|
||||
(j) => (j.rows || []).length,
|
||||
'Doxa people group(s)',
|
||||
)
|
||||
console.log(`DOXA-PG: ${rows.length} group(s) across ${Object.keys(byCountry).length} countries`)
|
||||
console.log(` placed on the map: ${mapped} no coordinates yet: ${unmapped} ambiguous, refused: ${ambiguous}`)
|
||||
console.log(` of those placed, ${reordered} matched on the same words in the other order`)
|
||||
console.log(` countries holding 2 or fewer Doxa groups: ${thin} — a percentage over that denominator is not a measurement`)
|
||||
console.log(`wrote ${OUT}`)
|
||||
|
|
@ -52,6 +52,7 @@
|
|||
panes with their direct-element mount + heavy-row teardown machinery. -->
|
||||
<VerticalTabsLayout
|
||||
:title="heading || tr('title')"
|
||||
:dir="uiDir"
|
||||
:nav-label="tr('navLabel')"
|
||||
:labels="{ toggleList: tr('toggleList'), openViews: tr('openViews'), expandList: tr('expandList'), collapseList: tr('collapseList'), collapse: tr('collapse') }"
|
||||
:header="true"
|
||||
|
|
@ -547,7 +548,11 @@ export default defineComponent({
|
|||
const nb = new Set(this.rows.map(r => r.bundle)).size
|
||||
return this.tr('countLabel', { bundles: nb, elements: this.rows.length })
|
||||
},
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
|
||||
// Right-to-left for a right-to-left language (languages.js marks Arabic dir:'rtl'), which is what
|
||||
// switches on VerticalTabsLayout's :dir(rtl) mirror — rega's 5e7f73d0, which doxa-maps-spa turns on
|
||||
// the same way. Without this the mirror compiled into the studio but never applied.
|
||||
uiDir() { return (this.activeLang && this.activeLang.dir) === 'rtl' ? 'rtl' : 'ltr' }
|
||||
},
|
||||
mounted() {
|
||||
this.loadManifest()
|
||||
|
|
|
|||
|
|
@ -106,11 +106,11 @@ const ST_CSS = '' +
|
|||
'.st-tool:hover { background:#f3f3f1; color:#3b463d; } .st-tool:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }' +
|
||||
'.st-tool svg { width:16px; height:16px; }' +
|
||||
'.st-tree { display:flex; flex-direction:column; padding:0 0 8px; outline:0; }' +
|
||||
'.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding:0 10px 0 calc(8px + var(--st-depth) * 14px); font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }' +
|
||||
'.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding-block:0; padding-inline:calc(8px + var(--st-depth) * 14px) 10px; font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }' +
|
||||
'.st-row:hover { background:#f3f3f1; }' +
|
||||
'.st-row:focus-visible { box-shadow:inset 0 0 0 1px #92b195; }' +
|
||||
'.st-row.active { background:rgba(146,177,149,.24); }' +
|
||||
'.st-row.active::after { content:""; position:absolute; left:0; top:4px; bottom:4px; width:3px; border-radius:0 3px 3px 0; background:#92b195; }' +
|
||||
'.st-row.active::after { content:""; position:absolute; inset-inline-start:0; top:4px; bottom:4px; width:3px; border-start-end-radius:3px; border-end-end-radius:3px; background:#92b195; }' +
|
||||
'.st-row.alias.active { background:rgba(146,177,149,.12); }' +
|
||||
'.st-sec { position:sticky; top:46px; z-index:2; height:30px; margin-top:6px; background:#ffffff; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:#6d766e; border-top:1px solid #eceee9; }' +
|
||||
'.st-sec:first-child { margin-top:0; border-top:0; }' +
|
||||
|
|
@ -120,6 +120,7 @@ const ST_CSS = '' +
|
|||
'.st-twisty, .st-twisty-sp { flex:0 0 16px; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; color:#6d766e; }' +
|
||||
'.st-twisty svg { width:14px; height:14px; transition:transform .12s ease; }' +
|
||||
'.st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }' +
|
||||
'.st:dir(rtl) .st-twisty svg { transform:scaleX(-1); } .st:dir(rtl) .st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }' +
|
||||
'.st-ic { flex:0 0 16px; display:inline-flex; color:#6d766e; } .st-ic svg { width:16px; height:16px; }' +
|
||||
'.st-row.active .st-ic { color:#5f8a63; }' +
|
||||
'.st-label { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }' +
|
||||
|
|
@ -127,12 +128,12 @@ const ST_CSS = '' +
|
|||
// ellipsised to make room for the tag (VS Code truncates the description first, too).
|
||||
'.st-desc { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; font:11px/1 ui-monospace,Menlo,monospace; color:#8a928b; }' +
|
||||
'.st-part { flex:0 0 auto; font:600 9px/1 system-ui,sans-serif; text-transform:uppercase; letter-spacing:.3px; padding:2px 4px; border:1px solid #92b195; border-radius:4px; color:#5f8a63; }' +
|
||||
'.st-count { flex:0 0 auto; margin-left:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }' +
|
||||
'.st-guide { position:absolute; top:0; bottom:0; left:calc(15px + var(--st-g) * 14px); border-left:1px solid #dfe2db; pointer-events:none; }' +
|
||||
'.st-row:hover > .st-guide { border-left-color:#c4c8c0; }' +
|
||||
'.st-count { flex:0 0 auto; margin-inline-start:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }' +
|
||||
'.st-guide { position:absolute; top:0; bottom:0; inset-inline-start:calc(15px + var(--st-g) * 14px); border-inline-start:1px solid #dfe2db; pointer-events:none; }' +
|
||||
'.st-row:hover > .st-guide { border-inline-start-color:#c4c8c0; }' +
|
||||
'.st-empty { margin:12px; font-size:13px; color:#6d766e; }' +
|
||||
'.st-activity { display:flex; flex-direction:column; gap:4px; }' +
|
||||
'.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-left:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }' +
|
||||
'.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-inline-start:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }' +
|
||||
'.st-act:hover, .st-act.active { background:#f3f3f1; color:#3b463d; }' +
|
||||
'.st-act:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }' +
|
||||
'.st-act-ic { display:inline-flex; } .st-act-ic svg { width:20px; height:20px; }' +
|
||||
|
|
@ -352,11 +353,11 @@ export default defineComponent({
|
|||
.st-tool:hover { background:#f3f3f1; color:#3b463d; } .st-tool:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }
|
||||
.st-tool svg { width:16px; height:16px; }
|
||||
.st-tree { display:flex; flex-direction:column; padding:0 0 8px; outline:0; }
|
||||
.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding:0 10px 0 calc(8px + var(--st-depth) * 14px); font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }
|
||||
.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding-block:0; padding-inline:calc(8px + var(--st-depth) * 14px) 10px; font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }
|
||||
.st-row:hover { background:#f3f3f1; }
|
||||
.st-row:focus-visible { box-shadow:inset 0 0 0 1px #92b195; }
|
||||
.st-row.active { background:rgba(146,177,149,.24); }
|
||||
.st-row.active::after { content:""; position:absolute; left:0; top:4px; bottom:4px; width:3px; border-radius:0 3px 3px 0; background:#92b195; }
|
||||
.st-row.active::after { content:""; position:absolute; inset-inline-start:0; top:4px; bottom:4px; width:3px; border-start-end-radius:3px; border-end-end-radius:3px; background:#92b195; }
|
||||
.st-row.alias.active { background:rgba(146,177,149,.12); }
|
||||
.st-sec { position:sticky; top:46px; z-index:2; height:30px; margin-top:6px; background:#ffffff; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:#6d766e; border-top:1px solid #eceee9; }
|
||||
.st-sec:first-child { margin-top:0; border-top:0; }
|
||||
|
|
@ -366,17 +367,18 @@ export default defineComponent({
|
|||
.st-twisty, .st-twisty-sp { flex:0 0 16px; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; color:#6d766e; }
|
||||
.st-twisty svg { width:14px; height:14px; transition:transform .12s ease; }
|
||||
.st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }
|
||||
.st:dir(rtl) .st-twisty svg { transform:scaleX(-1); } .st:dir(rtl) .st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }
|
||||
.st-ic { flex:0 0 16px; display:inline-flex; color:#6d766e; } .st-ic svg { width:16px; height:16px; }
|
||||
.st-row.active .st-ic { color:#5f8a63; }
|
||||
.st-label { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
|
||||
.st-desc { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; font:11px/1 ui-monospace,Menlo,monospace; color:#8a928b; }
|
||||
.st-part { flex:0 0 auto; font:600 9px/1 system-ui,sans-serif; text-transform:uppercase; letter-spacing:.3px; padding:2px 4px; border:1px solid #92b195; border-radius:4px; color:#5f8a63; }
|
||||
.st-count { flex:0 0 auto; margin-left:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }
|
||||
.st-guide { position:absolute; top:0; bottom:0; left:calc(15px + var(--st-g) * 14px); border-left:1px solid #dfe2db; pointer-events:none; }
|
||||
.st-row:hover > .st-guide { border-left-color:#c4c8c0; }
|
||||
.st-count { flex:0 0 auto; margin-inline-start:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }
|
||||
.st-guide { position:absolute; top:0; bottom:0; inset-inline-start:calc(15px + var(--st-g) * 14px); border-inline-start:1px solid #dfe2db; pointer-events:none; }
|
||||
.st-row:hover > .st-guide { border-inline-start-color:#c4c8c0; }
|
||||
.st-empty { margin:12px; font-size:13px; color:#6d766e; }
|
||||
.st-activity { display:flex; flex-direction:column; gap:4px; }
|
||||
.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-left:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }
|
||||
.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-inline-start:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }
|
||||
.st-act:hover, .st-act.active { background:#f3f3f1; color:#3b463d; }
|
||||
.st-act:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }
|
||||
.st-act-ic { display:inline-flex; } .st-act-ic svg { width:20px; height:20px; }
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ onBeforeUnmount(() => {
|
|||
const SHARE_EMBED_PATH = 'regions-dashboard/regions-mfe/index.html'
|
||||
|
||||
// NO BACKTICKS inside this CSS string, including in comments — one terminates the JS template
|
||||
// literal and the build stops compiling (house style, AGENT-BRIEF.md).
|
||||
// literal and the build stops compiling (house style).
|
||||
useShadowStyles(PANE_GRID_STYLES + '\n' +
|
||||
// SELF-SIZE. Every layer inside is absolute/height:100% (.rmfe -> .pane-col -> .pane), so the
|
||||
// chain resolves to ZERO unless the HOST itself is sized — the "elements are not embedding"
|
||||
|
|
|
|||
|
|
@ -22,4 +22,4 @@ another map. Two surfaces sit side by side:
|
|||
**Writing a strategy?** The full contract (required `export default` shape, checklist,
|
||||
profile-local overrides) is in
|
||||
**[../../../../documentation/architecture/color-strategies.md](../../../../documentation/architecture/color-strategies.md)**.
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../contributing/COLORS.md).
|
||||
Two places a color can live, in depth: [`../../../../contributing/COLORS.md`](../../../../documentation/contributing/COLORS.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
48584
public/doxa-maps/assets/tables/doxa-gospel-access.json
generated
Normal file
48584
public/doxa-maps/assets/tables/doxa-gospel-access.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46370
public/doxa-maps/assets/tables/doxa-people-groups.json
generated
Normal file
46370
public/doxa-maps/assets/tables/doxa-people-groups.json
generated
Normal file
File diff suppressed because it is too large
Load diff
55
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
55
public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-2DhxO9yoXoHDC5EBpkZtH5t0kYmDl0TrvhsQaKq0M4E05wXYJNjfAN2F+kHvuiNm" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-2DhxO9yoXoHDC5EBpkZtH5t0kYmDl0TrvhsQaKq0M4E05wXYJNjfAN2F+kHvuiNm" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
80
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
80
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
|
||||
<script src="../gospel-resources.js" integrity="sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-resources.js" integrity="sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
|
||||
<script src="../gospel-resources.js" integrity="sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-resources.js" integrity="sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
|
||||
<script src="../gospel-resources.js" integrity="sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-resources.js" integrity="sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
|
||||
<script src="../gospel-resources.js" integrity="sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-resources.js" integrity="sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
|
||||
<script src="../gospel-resources.js" integrity="sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L" crossorigin="anonymous"></script>
|
||||
<script src="../gospel-resources.js" integrity="sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
2
public/doxa-maps/index.html
generated
File diff suppressed because one or more lines are too long
28
public/doxa-maps/manifest.json
generated
28
public/doxa-maps/manifest.json
generated
|
|
@ -13,7 +13,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "doxa-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS",
|
||||
"integrity": "sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-j2rnWAW0lVUG05WREAfkQZo0jtreSIebwV3nHCbUcXhAGzMfTESwA1PqTYy5M3QS\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-wDwlEPqFMo1DGCSAZQqHdBUM9OKTByNW1wSZPNu5auSubOuSBz4eHR4CIf0uvglf\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "doxa-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa",
|
||||
"integrity": "sha384-2DhxO9yoXoHDC5EBpkZtH5t0kYmDl0TrvhsQaKq0M4E05wXYJNjfAN2F+kHvuiNm",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -327,7 +327,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-0kpyOLYSyDAS7sKrvfh60jFxPqlWP7Gi4ftn1rGwNmEzdSLY9ijkowlKw77v/3Xa\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-2DhxO9yoXoHDC5EBpkZtH5t0kYmDl0TrvhsQaKq0M4E05wXYJNjfAN2F+kHvuiNm\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -396,7 +396,7 @@
|
|||
"bundle": "doxa-simple-map",
|
||||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"output": "doxa-maps/doxa-simple-map/doxa-simple-map.js",
|
||||
"integrity": "sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2",
|
||||
"integrity": "sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD",
|
||||
"elements": [
|
||||
"doxa-map",
|
||||
"doxa-simple-map"
|
||||
|
|
@ -413,7 +413,7 @@
|
|||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-G/NuJx27ipfilwAaJ9N9jsGgKtVlSMwkS9E/dv/NE5drDd56Z5KYewcu0hp3H2N2\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-9zRii7R8qYNcQie5wWdj4FOyXxc4f05gn8QvF6ynAs6OakpTBmEXxblDPyd4EUaD\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -596,7 +596,7 @@
|
|||
"bundle": "gospel-resources",
|
||||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"output": "doxa-maps/gospel-resources/gospel-resources.js",
|
||||
"integrity": "sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L",
|
||||
"integrity": "sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy",
|
||||
"elements": [
|
||||
"gospel-resources-map"
|
||||
],
|
||||
|
|
@ -616,7 +616,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -630,7 +630,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -644,7 +644,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -658,7 +658,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -672,7 +672,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-AjjbBKkq8fVOsJWVdoy6H4qWWrdMA82wWWH9lI3tNJ0j+MFCBtXfCXDabk4vR8+L\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-wIFvK6NOI8f5EHK6oloy1ozDFcOTHlqzpRzTODdmRWesLcT463c0ZNu4o3sZDPwy\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -799,7 +799,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "doxa-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3",
|
||||
"integrity": "sha384-N4FyC5ChLghkI4LgC9cG0fLIYdghhRuFMlHl7XexCe+JrU0oeC6ZKORJ0c7whPI9",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -827,7 +827,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-N4FyC5ChLghkI4LgC9cG0fLIYdghhRuFMlHl7XexCe+JrU0oeC6ZKORJ0c7whPI9\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
|
|||
46
public/doxa-maps/plugin-studio/plugin-studio.js
generated
46
public/doxa-maps/plugin-studio/plugin-studio.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
|
||||
<script src="../plugin-studio.js" integrity="sha384-N4FyC5ChLghkI4LgC9cG0fLIYdghhRuFMlHl7XexCe+JrU0oeC6ZKORJ0c7whPI9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../../plugin-studio.js" integrity="sha384-NXCBL46038pXHJb8lEhrC/U2ZIBLvsevalJdp9/OU/8cp3LGx2TinkGoY6j8usV3" crossorigin="anonymous"></script>
|
||||
<script src="../../plugin-studio.js" integrity="sha384-N4FyC5ChLghkI4LgC9cG0fLIYdghhRuFMlHl7XexCe+JrU0oeC6ZKORJ0c7whPI9" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue