Move internal files out of the repo; fix five dead links; a fuller delegation diagram
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Moved out (kept outside the repo, in the team's internal tracker):
- AGENT-BRIEF.md: operating rules for the team's own agents
- documentation/slide-deck-outline.md, documentation/README-longform.md:
  internal pitch material; the README and why.md cover the same ground
- plugin-designs/gospel-resources/SOURCES-CHECKED-AND-REJECTED.md: research log
- .claude/ (an editor status line), .forgejo/ (a CI workflow that never ran),
  _archive/ (a retired staging page; already gitignored but still tracked)
Seven code comments that named these files now describe the rule instead.

Fixed five dead links left from the docs/ -> documentation/ move, including
the one in template-bundle that every new plugin inherits.

The delegation diagram now shows the whole model: the Plugin Studio with the
shared library and the merge queue into main, seven production plugins with
capability badges and the Experimental shelf, one agent per plugin (a roaming
agent for the shelf), the shepherd's oversight ring, a legend, and the five
ways a build ships.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 05:13:52 +00:00
parent 7eaa4012c1
commit 4d773d6ee8
23 changed files with 168 additions and 1665 deletions

View file

@ -1,7 +0,0 @@
{
"statusLine": {
"type": "command",
"command": "bash .claude/statusline-plugin.sh",
"padding": 0
}
}

View file

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

View file

@ -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}"

View file

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

View file

@ -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>&lt;name&gt;.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">&lt;custom-element&gt;</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>

View file

@ -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">&lt;' + (b.tag || '??') + '&gt;</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">&lt;' + t + '&gt;</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())

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@
* - 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 (see AGENT-BRIEF.md) — so this one refuses to guess.
* another worktree's tree — so this one refuses to guess.
*/
import { chromium } from 'playwright'

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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