Doxa Maps — public snapshot refresh
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Everything the flights landed since the first snapshot: all 11 interface
languages complete, the gospel-resources campaign (audio Scripture pins for
2,121 languages, the Deaf-gap completion, 3,603 resource links verified), the
shared poster wall with print-in-place, prayer-walk sharing, and the 34-gate
build pipeline. Tree rebuilt from source; SRI consistent; hygiene clean.
This commit is contained in:
Doxa Maps 2026-09-24 15:03:37 +00:00
parent 19e699a174
commit f9433cfb63
521 changed files with 231437 additions and 53107 deletions

17
.gitignore vendored
View file

@ -115,5 +115,18 @@ internal-tracker/
# ── GENERATOR OUTPUT — regenerable render artifacts, never committed ──────────
# internal/<tool>/out/ holds what the poster/research generators RENDER
# (upg100-posters/out alone is ~619MB / 12,623 PNGs). The generators themselves
# are source and ARE committed; their output is derived. What actually ships is
# the exported subset in public/assets/ (see export-public.mjs).
# are source and ARE committed; their output is derived.
#
# ⚠ THIS BLOCK USED TO SAY "what actually ships is the exported subset in
# public/assets/ (see export-public.mjs)". BOTH HALVES OF THAT WERE STALE, and
# the sentence is why 80.7 MB sat committed twice:
# - export-public.mjs does NOT write public/assets/. It writes a SIBLING OF THE
# REPO (../../../../upg-100-posters, or $DOXA_PUBLIC), and says so in its own
# header — explicitly "NOT inside public/, because capacitor packages public/
# whole and these are 52 MB that must not ship in the APK".
# - public/assets/<name> is now a GENERATED MIRROR of the tracked data/<name>,
# rewritten by generate-tree.mjs on every `bun run tree`. It is derived, not
# exported, so it is ignored in public/.gitignore — which carries the
# measurements and the one exception (previews/, which has no data/ source
# and must stay tracked).
# The tracked source for shipped assets is data/<name>.

View file

@ -219,8 +219,8 @@ Four steps from nothing to every map in one place.
```bash
# 1. Clone (the repo root IS the studio — there is no subfolder to cd into).
# This host is PRIVATE: it will prompt for your the forge username + token.
git clone --branch doxa-maps-plugin-studio https://the-git-host/the-source-repository.git
# This host is PRIVATE: it will prompt for your the git forge username + token.
git clone --branch doxa-maps-plugin-studio https://your-git-host.example/doxa-maps.git
cd doxa-maps
# 2. Install dependencies (installs 206 packages; needs Node >= 18)
@ -239,9 +239,9 @@ spot. (If port 5173 is busy Vite picks another one and prints it; read the line
> **You do not need to build first.** The build output in `public/` is committed, so a fresh clone has
> plugins to show the moment the dev server starts.
> **If step 1 fails with `remote: Unauthorized`,** the clone is not broken — `the-git-host` is a
> private self-hosted the forge and anonymous cloning is refused. Ask for an account, then use your
> username plus a the forge **access token** (not your password) when git prompts.
> **If step 1 fails with `remote: Unauthorized`,** the clone is not broken — `your-git-host.example` is a
> private self-hosted the git forge and anonymous cloning is refused. Ask for an account, then use your
> username plus a the git forge **access token** (not your password) when git prompts.
Two things worth knowing:
@ -295,10 +295,10 @@ manifest. To check one bundle's compiled output without touching anyone else's:
BUNDLE=<name> bun x vite build # this bundle only; does NOT clean the tree
```
### Contributing changes
### Push as you go
Commit and push to your fork or branch as usual; small, frequently pushed commits are
preferred over large local ones.
easier to review and impossible to lose.
### The production build

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>legacy map host</title>
<style>html,body{margin:0;height:100%;background:#f3f3f1}.host{position:fixed;inset:0}</style>
</head>
<body>
<div class="host" id="host"></div>
<script>
// Sandboxed host for the LEGACY upg-neighbor IIFE (pre-bundler architecture). This page runs
// inside an IFRAME so the old engine and its dependencies cannot collide with the modern
// bundles -- the isolation pattern the roundup mandates ("add without breaking doxa-maps").
var q = new URLSearchParams(location.search);
var el = (q.get('el') || 'send-map').replace(/[^a-z0-9-]/g, '');
var tk = q.get('tk') || '';
// Token globals the legacy build may read.
window.MAPBOX_TOKEN = tk; window.MAP_TOKEN = tk; window.UPG_TOKEN = tk;
// The legacy code fetches relative "/api/people-groups/..." which only resolved on its old
// origin. Shim fetch so those calls reach the live API from ANY host (static tree, iframe).
var API = 'https://pray.doxa.life';
// The cockpit profile fails closed without a configured API base and names this global in
// its own error banner (measured: "No API base configured (apiBase / window.MAP_APP_API_URL
// / VITE_API_BASE_URL) -- failing closed").
window.MAP_APP_API_URL = API;
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf('/api/') === 0) return origFetch(API + url, init);
} catch (e) { /* fall through */ }
return origFetch(input, init);
};
// ── KEYLESS SHIM ──────────────────────────────────────────────────────────
// The legacy bundle expects a mapboxgl GLOBAL and requests mapbox://styles/mapbox/light-v11.
// MapLibre is API-compatible with the mapbox-gl v1 surface the bundle uses, and OpenFreeMap
// Positron is the keyless twin of light-v11 -- so the shim below makes every legacy map run
// WITHOUT a Mapbox token: load our self-hosted MapLibre, alias it as mapboxgl, and rewrite
// any mapbox:// style to the keyless URL inside a patched Map constructor.
var KEYLESS_STYLE = 'https://tiles.openfreemap.org/styles/positron';
var lib = document.createElement('link'); lib.rel='stylesheet'; lib.href='./maplibre-gl.css';
document.head.appendChild(lib);
var ml = document.createElement('script');
ml.src = './maplibre-gl.js';
ml.onload = function () {
var Real = window.maplibregl.Map;
function PatchedMap (opts) {
if (opts && typeof opts.style === 'string' && opts.style.indexOf('mapbox://') === 0) opts.style = KEYLESS_STYLE;
return new Real(opts);
}
PatchedMap.prototype = Real.prototype;
window.mapboxgl = Object.assign({}, window.maplibregl, { Map: PatchedMap, accessToken: '' });
loadLegacy();
};
ml.onerror = function () { loadLegacy(); };
document.head.appendChild(ml);
function loadLegacy () {
var s = document.createElement('script');
s.src = './upg-neighbor.iife.js';
s.onload = function () {
var node = document.createElement(el);
node.style.cssText = 'display:block;width:100%;height:100%';
// The legacy elements use the SAME profile-config attribute contract as the modern
// bundles (measured: without it the element renders its own usage banner).
node.setAttribute('profile-config', JSON.stringify({ profile: el, tk: tk }));
document.getElementById('host').appendChild(node);
};
s.onerror = function () { document.body.innerHTML = '<p style="font:14px system-ui;padding:20px">legacy bundle failed to load</p>'; };
document.head.appendChild(s);
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/brazil.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/deaf.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/europe.html Normal file

File diff suppressed because one or more lines are too long

355
data/posters/laos.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

355
data/posters/sudan.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

34214
data/tables/fcbh-index.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,758 @@
{
"_note": "Cru gospel presentations (GodTools) on knowgod.com, keyed by ISO 639-3. godtools.org is a PARKED DOMAIN. knowgod.com is a client-rendered shell that answers 200 with identical bytes for any URL, so existence is proved from Cru's own API by intersecting a resource's latest-translations with a language's translations — never from the page.",
"source": "https://mobile-content-api.cru.org/languages + https://mobile-content-api.cru.org/resources",
"site": "https://knowgod.com",
"generated": "2026-09-24",
"tools_allowed": [
"kgp (Knowing God Personally)",
"fourlaws (Four Spiritual Laws)",
"honorrestored (Honor Restored)",
"fantasticfacts (Four Fantastic Facts)",
"satisfied (Satisfied?)",
"connectingwithgod (Connecting With God)"
],
"counts": {
"languages": 123,
"resources": 45,
"codes": 105,
"noIso": 0,
"noTool": 9
},
"codes": {
"afr": {
"u": "https://knowgod.com/af/kgp",
"n": "Knowing God Personally",
"ln": "Afrikaans",
"locale": "af",
"tool": "kgp"
},
"sqi": {
"u": "https://knowgod.com/sq/kgp",
"n": "Knowing God Personally",
"ln": "Albanian",
"locale": "sq",
"tool": "kgp"
},
"amh": {
"u": "https://knowgod.com/am/kgp",
"n": "Knowing God Personally",
"ln": "Amharic",
"locale": "am",
"tool": "kgp"
},
"ara": {
"u": "https://knowgod.com/ar/kgp",
"n": "Knowing God Personally",
"ln": "Arabic",
"locale": "ar",
"tool": "kgp"
},
"hye": {
"u": "https://knowgod.com/hy/kgp",
"n": "Knowing God Personally",
"ln": "Armenian",
"locale": "hy",
"tool": "kgp"
},
"aze": {
"u": "https://knowgod.com/az/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Azerbaijani",
"locale": "az",
"tool": "fourlaws"
},
"bam": {
"u": "https://knowgod.com/bm/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Bambara",
"locale": "bm",
"tool": "fourlaws"
},
"bem": {
"u": "https://knowgod.com/bem/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Bemba",
"locale": "bem",
"tool": "fourlaws"
},
"ben": {
"u": "https://knowgod.com/bn/kgp",
"n": "Knowing God Personally",
"ln": "Bengali",
"locale": "bn",
"tool": "kgp"
},
"bis": {
"u": "https://knowgod.com/bi/kgp",
"n": "Knowing God Personally",
"ln": "Bislama",
"locale": "bi",
"tool": "kgp"
},
"bos": {
"u": "https://knowgod.com/bs-BA/kgp",
"n": "Knowing God Personally",
"ln": "Bosnian (Bosnia and Herzegovina)",
"locale": "bs-BA",
"tool": "kgp"
},
"bul": {
"u": "https://knowgod.com/bg/kgp",
"n": "Knowing God Personally",
"ln": "Bulgarian",
"locale": "bg",
"tool": "kgp"
},
"mya": {
"u": "https://knowgod.com/my/kgp",
"n": "Knowing God Personally",
"ln": "Burmese",
"locale": "my",
"tool": "kgp"
},
"ceb": {
"u": "https://knowgod.com/ceb/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Cebuano",
"locale": "ceb",
"tool": "fourlaws"
},
"nya": {
"u": "https://knowgod.com/ny/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Chichewa",
"locale": "ny",
"tool": "fourlaws"
},
"zho": {
"u": "https://knowgod.com/zh-Hans/kgp",
"n": "Knowing God Personally",
"ln": "Chinese Simplified",
"locale": "zh-Hans",
"tool": "kgp"
},
"hrv": {
"u": "https://knowgod.com/hr/kgp",
"n": "Knowing God Personally",
"ln": "Croatian",
"locale": "hr",
"tool": "kgp"
},
"ces": {
"u": "https://knowgod.com/cs/kgp",
"n": "Knowing God Personally",
"ln": "Czech",
"locale": "cs",
"tool": "kgp"
},
"dan": {
"u": "https://knowgod.com/da/kgp",
"n": "Knowing God Personally",
"ln": "Danish",
"locale": "da",
"tool": "kgp"
},
"nld": {
"u": "https://knowgod.com/nl/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Dutch",
"locale": "nl",
"tool": "fourlaws"
},
"dzo": {
"u": "https://knowgod.com/dz/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Dzongkha",
"locale": "dz",
"tool": "fourlaws"
},
"eng": {
"u": "https://knowgod.com/en/kgp",
"n": "Knowing God Personally",
"ln": "English",
"locale": "en",
"tool": "kgp"
},
"est": {
"u": "https://knowgod.com/et/kgp",
"n": "Knowing God Personally",
"ln": "Estonian (Estonia)",
"locale": "et",
"tool": "kgp"
},
"fij": {
"u": "https://knowgod.com/fj/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Fijian",
"locale": "fj",
"tool": "fourlaws"
},
"fil": {
"u": "https://knowgod.com/fil/kgp",
"n": "Knowing God Personally",
"ln": "Filipino",
"locale": "fil",
"tool": "kgp"
},
"fin": {
"u": "https://knowgod.com/fi/kgp",
"n": "Knowing God Personally",
"ln": "Finnish",
"locale": "fi",
"tool": "kgp"
},
"fra": {
"u": "https://knowgod.com/fr/kgp",
"n": "Knowing God Personally",
"ln": "French",
"locale": "fr",
"tool": "kgp"
},
"kat": {
"u": "https://knowgod.com/ka/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Georgian",
"locale": "ka",
"tool": "fourlaws"
},
"deu": {
"u": "https://knowgod.com/de/kgp",
"n": "Knowing God Personally",
"ln": "German",
"locale": "de",
"tool": "kgp"
},
"ell": {
"u": "https://knowgod.com/el/kgp",
"n": "Knowing God Personally",
"ln": "Greek",
"locale": "el",
"tool": "kgp"
},
"guj": {
"u": "https://knowgod.com/gu/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Gujarati",
"locale": "gu",
"tool": "fourlaws"
},
"hat": {
"u": "https://knowgod.com/ht-HT/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Haitian Creole",
"locale": "ht-HT",
"tool": "fourlaws"
},
"hau": {
"u": "https://knowgod.com/ha/kgp",
"n": "Knowing God Personally",
"ln": "Hausa",
"locale": "ha",
"tool": "kgp"
},
"heb": {
"u": "https://knowgod.com/he/kgp",
"n": "Knowing God Personally",
"ln": "Hebrew",
"locale": "he",
"tool": "kgp"
},
"hin": {
"u": "https://knowgod.com/hi/kgp",
"n": "Knowing God Personally",
"ln": "Hindi",
"locale": "hi",
"tool": "kgp"
},
"hmn": {
"u": "https://knowgod.com/hmn/honorrestored",
"n": "Honor Restored",
"ln": "Hmong",
"locale": "hmn",
"tool": "honorrestored"
},
"hun": {
"u": "https://knowgod.com/hu/kgp",
"n": "Knowing God Personally",
"ln": "Hungarian",
"locale": "hu",
"tool": "kgp"
},
"isl": {
"u": "https://knowgod.com/is-IS/kgp",
"n": "Knowing God Personally",
"ln": "Icelandic (Iceland)",
"locale": "is-IS",
"tool": "kgp"
},
"ibo": {
"u": "https://knowgod.com/ig/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Igbo",
"locale": "ig",
"tool": "fourlaws"
},
"ind": {
"u": "https://knowgod.com/id/kgp",
"n": "Knowing God Personally",
"ln": "Indonesian",
"locale": "id",
"tool": "kgp"
},
"ita": {
"u": "https://knowgod.com/it/kgp",
"n": "Knowing God Personally",
"ln": "Italian",
"locale": "it",
"tool": "kgp"
},
"jpn": {
"u": "https://knowgod.com/ja/kgp",
"n": "Knowing God Personally",
"ln": "Japanese",
"locale": "ja",
"tool": "kgp"
},
"jav": {
"u": "https://knowgod.com/jv/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Javanese",
"locale": "jv",
"tool": "fourlaws"
},
"kan": {
"u": "https://knowgod.com/kn/kgp",
"n": "Knowing God Personally",
"ln": "Kannada",
"locale": "kn",
"tool": "kgp"
},
"kaz": {
"u": "https://knowgod.com/kk/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Kazakh",
"locale": "kk",
"tool": "fourlaws"
},
"kha": {
"u": "https://knowgod.com/kha/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Khasi",
"locale": "kha",
"tool": "fourlaws"
},
"khm": {
"u": "https://knowgod.com/km-KH/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Khmer",
"locale": "km-KH",
"tool": "fourlaws"
},
"kik": {
"u": "https://knowgod.com/ki/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Kikuyu",
"locale": "ki",
"tool": "fourlaws"
},
"kin": {
"u": "https://knowgod.com/rw/honorrestored",
"n": "Honor Restored",
"ln": "Kinyarwanda",
"locale": "rw",
"tool": "honorrestored"
},
"run": {
"u": "https://knowgod.com/rn/kgp",
"n": "Knowing God Personally",
"ln": "Kirundi",
"locale": "rn",
"tool": "kgp"
},
"ktu": {
"u": "https://knowgod.com/ktu/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Kituba",
"locale": "ktu",
"tool": "fourlaws"
},
"kor": {
"u": "https://knowgod.com/ko/kgp",
"n": "Knowing God Personally",
"ln": "Korean",
"locale": "ko",
"tool": "kgp"
},
"ckb": {
"u": "https://knowgod.com/ckb/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Kurdish Sorani",
"locale": "ckb",
"tool": "fourlaws"
},
"kir": {
"u": "https://knowgod.com/ky/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Kyrgyz",
"locale": "ky",
"tool": "fourlaws"
},
"lao": {
"u": "https://knowgod.com/lo/kgp",
"n": "Knowing God Personally",
"ln": "Lao",
"locale": "lo",
"tool": "kgp"
},
"lav": {
"u": "https://knowgod.com/lv/kgp",
"n": "Knowing God Personally",
"ln": "Latvian",
"locale": "lv",
"tool": "kgp"
},
"lin": {
"u": "https://knowgod.com/ln/kgp",
"n": "Knowing God Personally",
"ln": "Lingala",
"locale": "ln",
"tool": "kgp"
},
"lit": {
"u": "https://knowgod.com/lt-LT/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Lithuanian",
"locale": "lt-LT",
"tool": "fourlaws"
},
"lug": {
"u": "https://knowgod.com/lg/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Luganda",
"locale": "lg",
"tool": "fourlaws"
},
"mkd": {
"u": "https://knowgod.com/mk/kgp",
"n": "Knowing God Personally",
"ln": "Macedonian",
"locale": "mk",
"tool": "kgp"
},
"mlg": {
"u": "https://knowgod.com/mg/kgp",
"n": "Knowing God Personally",
"ln": "Malagasy",
"locale": "mg",
"tool": "kgp"
},
"msa": {
"u": "https://knowgod.com/ms/kgp",
"n": "Knowing God Personally",
"ln": "Malay",
"locale": "ms",
"tool": "kgp"
},
"mal": {
"u": "https://knowgod.com/ml/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Malayalam",
"locale": "ml",
"tool": "fourlaws"
},
"mri": {
"u": "https://knowgod.com/mi/kgp",
"n": "Knowing God Personally",
"ln": "Māori",
"locale": "mi",
"tool": "kgp"
},
"mar": {
"u": "https://knowgod.com/mr/kgp",
"n": "Knowing God Personally",
"ln": "Marathi",
"locale": "mr",
"tool": "kgp"
},
"mon": {
"u": "https://knowgod.com/mn/kgp",
"n": "Knowing God Personally",
"ln": "Mongolian",
"locale": "mn",
"tool": "kgp"
},
"nep": {
"u": "https://knowgod.com/ne/kgp",
"n": "Knowing God Personally",
"ln": "Nepali",
"locale": "ne",
"tool": "kgp"
},
"nor": {
"u": "https://knowgod.com/no/kgp",
"n": "Knowing God Personally",
"ln": "Norwegian",
"locale": "no",
"tool": "kgp"
},
"ori": {
"u": "https://knowgod.com/or/kgp",
"n": "Knowing God Personally",
"ln": "Oriya",
"locale": "or",
"tool": "kgp"
},
"orm": {
"u": "https://knowgod.com/om/kgp",
"n": "Knowing God Personally",
"ln": "Oromo",
"locale": "om",
"tool": "kgp"
},
"pap": {
"u": "https://knowgod.com/pap/kgp",
"n": "Knowing God Personally",
"ln": "Papiamentu",
"locale": "pap",
"tool": "kgp"
},
"pus": {
"u": "https://knowgod.com/ps/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Pashto",
"locale": "ps",
"tool": "fourlaws"
},
"fas": {
"u": "https://knowgod.com/fa/kgp",
"n": "Knowing God Personally",
"ln": "Persian",
"locale": "fa",
"tool": "kgp"
},
"pol": {
"u": "https://knowgod.com/pl/kgp",
"n": "Knowing God Personally",
"ln": "Polish",
"locale": "pl",
"tool": "kgp"
},
"por": {
"u": "https://knowgod.com/pt/kgp",
"n": "Knowing God Personally",
"ln": "Portuguese",
"locale": "pt",
"tool": "kgp"
},
"pan": {
"u": "https://knowgod.com/pa/kgp",
"n": "Knowing God Personally",
"ln": "Punjabi",
"locale": "pa",
"tool": "kgp"
},
"ron": {
"u": "https://knowgod.com/ro/kgp",
"n": "Knowing God Personally",
"ln": "Romanian",
"locale": "ro",
"tool": "kgp"
},
"rus": {
"u": "https://knowgod.com/ru/kgp",
"n": "Knowing God Personally",
"ln": "Russian",
"locale": "ru",
"tool": "kgp"
},
"smo": {
"u": "https://knowgod.com/sm/kgp",
"n": "Knowing God Personally",
"ln": "Samoan",
"locale": "sm",
"tool": "kgp"
},
"srp": {
"u": "https://knowgod.com/sr-ME/kgp",
"n": "Knowing God Personally",
"ln": "Serbian (Montenegro)",
"locale": "sr-ME",
"tool": "kgp"
},
"sna": {
"u": "https://knowgod.com/sn/kgp",
"n": "Knowing God Personally",
"ln": "Shona",
"locale": "sn",
"tool": "kgp"
},
"sid": {
"u": "https://knowgod.com/sid/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Sidama",
"locale": "sid",
"tool": "fourlaws"
},
"sin": {
"u": "https://knowgod.com/si/kgp",
"n": "Knowing God Personally",
"ln": "Sinhala",
"locale": "si",
"tool": "kgp"
},
"slk": {
"u": "https://knowgod.com/sk/kgp",
"n": "Knowing God Personally",
"ln": "Slovak",
"locale": "sk",
"tool": "kgp"
},
"slv": {
"u": "https://knowgod.com/sl/kgp",
"n": "Knowing God Personally",
"ln": "Slovenian",
"locale": "sl",
"tool": "kgp"
},
"som": {
"u": "https://knowgod.com/so/kgp",
"n": "Knowing God Personally",
"ln": "Somali",
"locale": "so",
"tool": "kgp"
},
"spa": {
"u": "https://knowgod.com/es/kgp",
"n": "Knowing God Personally",
"ln": "Spanish",
"locale": "es",
"tool": "kgp"
},
"swa": {
"u": "https://knowgod.com/sw/kgp",
"n": "Knowing God Personally",
"ln": "Swahili",
"locale": "sw",
"tool": "kgp"
},
"swe": {
"u": "https://knowgod.com/sv/kgp",
"n": "Knowing God Personally",
"ln": "Swedish",
"locale": "sv",
"tool": "kgp"
},
"tgk": {
"u": "https://knowgod.com/tg/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Tajik",
"locale": "tg",
"tool": "fourlaws"
},
"tam": {
"u": "https://knowgod.com/ta/kgp",
"n": "Knowing God Personally",
"ln": "Tamil",
"locale": "ta",
"tool": "kgp"
},
"tel": {
"u": "https://knowgod.com/te-IN/kgp",
"n": "Knowing God Personally",
"ln": "Telugu (India)",
"locale": "te-IN",
"tool": "kgp"
},
"tet": {
"u": "https://knowgod.com/tet/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Tetun",
"locale": "tet",
"tool": "fourlaws"
},
"tha": {
"u": "https://knowgod.com/th/kgp",
"n": "Knowing God Personally",
"ln": "Thai",
"locale": "th",
"tool": "kgp"
},
"bod": {
"u": "https://knowgod.com/bo/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Tibetan",
"locale": "bo",
"tool": "fourlaws"
},
"tpi": {
"u": "https://knowgod.com/tpi/kgp",
"n": "Knowing God Personally",
"ln": "Tok Pisin",
"locale": "tpi",
"tool": "kgp"
},
"ton": {
"u": "https://knowgod.com/to/kgp",
"n": "Knowing God Personally",
"ln": "Tongan",
"locale": "to",
"tool": "kgp"
},
"tur": {
"u": "https://knowgod.com/tr/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Turkish",
"locale": "tr",
"tool": "fourlaws"
},
"ukr": {
"u": "https://knowgod.com/uk/kgp",
"n": "Knowing God Personally",
"ln": "Ukrainian",
"locale": "uk",
"tool": "kgp"
},
"urd": {
"u": "https://knowgod.com/ur/kgp",
"n": "Knowing God Personally",
"ln": "Urdu",
"locale": "ur",
"tool": "kgp"
},
"uzb": {
"u": "https://knowgod.com/uz/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Uzbek",
"locale": "uz",
"tool": "fourlaws"
},
"vie": {
"u": "https://knowgod.com/vi/kgp",
"n": "Knowing God Personally",
"ln": "Vietnamese",
"locale": "vi",
"tool": "kgp"
},
"cym": {
"u": "https://knowgod.com/cy/kgp",
"n": "Knowing God Personally",
"ln": "Welsh",
"locale": "cy",
"tool": "kgp"
},
"yor": {
"u": "https://knowgod.com/yo/kgp",
"n": "Knowing God Personally",
"ln": "Yoruba",
"locale": "yo",
"tool": "kgp"
},
"zul": {
"u": "https://knowgod.com/zu/fourlaws",
"n": "Four Spiritual Laws",
"ln": "Zulu",
"locale": "zu",
"tool": "fourlaws"
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -44,7 +44,7 @@
},
{
"name": "deaf-gap",
"rows": 120
"rows": 234
},
{
"name": "open-doors",
@ -61,6 +61,18 @@
{
"name": "gaps-and-opportunities",
"rows": 2106
},
{
"name": "people-group-pins",
"rows": 12379
},
{
"name": "gospel-resource-links",
"rows": 3550
},
{
"name": "gospel-gaps",
"rows": 2901
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -5363,7 +5363,7 @@
},
{
"operator": "Air Force Systems Networking",
"operator_raw": "a tracked issue - Air Force Systems Networking",
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
"asn": "AS61",
"country_iso3": "ASM",
"country_iso2": "US",
@ -29459,7 +29459,7 @@
},
{
"operator": "Air Force Systems Networking",
"operator_raw": "a tracked issue - Air Force Systems Networking",
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
"asn": "AS61",
"country_iso3": "GUM",
"country_iso2": "US",
@ -48167,7 +48167,7 @@
},
{
"operator": "Air Force Systems Networking",
"operator_raw": "a tracked issue - Air Force Systems Networking",
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
"asn": "AS61",
"country_iso3": "MNP",
"country_iso2": "US",
@ -61750,8 +61750,8 @@
"source": "RIPEstat"
},
{
"operator": "a tracked issue Ministry of Education and Higher Education",
"operator_raw": "a tracked issue Ministry of Education and Higher Education",
"operator": "QA-MOEHE-QA Ministry of Education and Higher Education",
"operator_raw": "QA-MOEHE-QA Ministry of Education and Higher Education",
"asn": "AS198464",
"country_iso3": "QAT",
"country_iso2": "QA",
@ -61774,8 +61774,8 @@
"source": "RIPEstat"
},
{
"operator": "a tracked issue Ministry of Communication and Information Technology",
"operator_raw": "a tracked issue Ministry of Communication and Information Technology - Qatar",
"operator": "QA-MCIT-AS Ministry of Communication and Information Technology",
"operator_raw": "QA-MCIT-AS Ministry of Communication and Information Technology - Qatar",
"asn": "AS201680",
"country_iso3": "QAT",
"country_iso2": "QA",
@ -72347,7 +72347,7 @@
},
{
"operator": "Air Force Systems Networking",
"operator_raw": "a tracked issue - Air Force Systems Networking",
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
"asn": "AS61",
"country_iso3": "USA",
"country_iso2": "US",
@ -75155,7 +75155,7 @@
},
{
"operator": "Air Force Systems Networking",
"operator_raw": "a tracked issue - Air Force Systems Networking",
"operator_raw": "DEC-MARLBORO-AS - Air Force Systems Networking",
"asn": "AS61",
"country_iso3": "VIR",
"country_iso2": "US",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

851
data/tables/zume-index.json Normal file
View file

@ -0,0 +1,851 @@
{
"source": "https://zume.training/sitemap.xml + https://zume.training/articles/zume-languages",
"fetched": "2026-09-24",
"locales": 62,
"live": 54,
"codes": 52,
"approxCodes": 3,
"byIso": {
"amh": {
"locale": "am",
"name": "Amharic",
"url": "https://zume.training/am"
},
"arb": {
"locale": "ar",
"name": "Arabic",
"url": "https://zume.training/ar"
},
"ajp": {
"locale": "ar_jo",
"name": "Arabic (Jordanian)",
"url": "https://zume.training/ar_jo"
},
"aeb": {
"locale": "ar_tn",
"name": "Arabic (Tunisian)",
"url": "https://zume.training/ar_tn"
},
"ase": {
"locale": "asl",
"name": "American Sign Language",
"url": "https://zume.training/asl"
},
"bho": {
"locale": "bho",
"name": "Bhojpuri",
"url": "https://zume.training/bho"
},
"ben": {
"locale": "bn",
"name": "Bengali (India)",
"url": "https://zume.training/bn"
},
"bos": {
"locale": "bs",
"name": "Bosnian",
"url": "https://zume.training/bs"
},
"deu": {
"locale": "de",
"name": "German",
"url": "https://zume.training/de"
},
"eng": {
"locale": "en",
"name": "English",
"url": "https://zume.training/en"
},
"spa": {
"locale": "es",
"name": "Spanish",
"url": "https://zume.training/es"
},
"pes": {
"locale": "fa",
"name": "Persian/Farsi",
"url": "https://zume.training/fa"
},
"fra": {
"locale": "fr",
"name": "French",
"url": "https://zume.training/fr"
},
"guj": {
"locale": "gu",
"name": "Gujarati",
"url": "https://zume.training/gu"
},
"hau": {
"locale": "ha",
"name": "Hausa",
"url": "https://zume.training/ha"
},
"hin": {
"locale": "hi",
"name": "Hindi",
"url": "https://zume.training/hi"
},
"hrv": {
"locale": "hr",
"name": "Croatian",
"url": "https://zume.training/hr"
},
"hye": {
"locale": "hy",
"name": "Armenian",
"url": "https://zume.training/hy"
},
"ind": {
"locale": "id",
"name": "Indonesian",
"url": "https://zume.training/id"
},
"ita": {
"locale": "it",
"name": "Italian",
"url": "https://zume.training/it"
},
"jpn": {
"locale": "ja",
"name": "Japanese",
"url": "https://zume.training/ja"
},
"kan": {
"locale": "kn",
"name": "Kannada",
"url": "https://zume.training/kn"
},
"kor": {
"locale": "ko",
"name": "Korean",
"url": "https://zume.training/ko"
},
"ckb": {
"locale": "ku",
"name": "Kurdish",
"url": "https://zume.training/ku"
},
"kir": {
"locale": "ky",
"name": "ky",
"url": "https://zume.training/ky"
},
"lao": {
"locale": "lo",
"name": "Lao",
"url": "https://zume.training/lo"
},
"mai": {
"locale": "mai",
"name": "Maithili",
"url": "https://zume.training/mai"
},
"mal": {
"locale": "ml",
"name": "Malayalam",
"url": "https://zume.training/ml"
},
"mar": {
"locale": "mr",
"name": "Marathi",
"url": "https://zume.training/mr"
},
"zlm": {
"locale": "ms",
"name": "Malay",
"url": "https://zume.training/ms"
},
"mya": {
"locale": "my",
"name": "Burmese",
"url": "https://zume.training/my"
},
"npi": {
"locale": "ne",
"name": "Nepali",
"url": "https://zume.training/ne"
},
"ory": {
"locale": "or",
"name": "Odia",
"url": "https://zume.training/or"
},
"pan": {
"locale": "pa",
"name": "Punjabi",
"url": "https://zume.training/pa"
},
"pnb": {
"locale": "pa_pk",
"name": "Punjabi (Western)",
"url": "https://zume.training/pa_pk"
},
"pol": {
"locale": "pl",
"name": "Polish",
"url": "https://zume.training/pl"
},
"prs": {
"locale": "prs",
"name": "prs",
"url": "https://zume.training/prs"
},
"por": {
"locale": "pt",
"name": "Portuguese",
"url": "https://zume.training/pt"
},
"ron": {
"locale": "ro",
"name": "Romanian",
"url": "https://zume.training/ro"
},
"rus": {
"locale": "ru",
"name": "Russian",
"url": "https://zume.training/ru"
},
"slv": {
"locale": "sl",
"name": "Slovenian",
"url": "https://zume.training/sl"
},
"som": {
"locale": "so",
"name": "Somali",
"url": "https://zume.training/so"
},
"swh": {
"locale": "swa",
"name": "Swahili",
"url": "https://zume.training/swa"
},
"tam": {
"locale": "ta",
"name": "Tamil",
"url": "https://zume.training/ta"
},
"tel": {
"locale": "te",
"name": "Telugu",
"url": "https://zume.training/te"
},
"tha": {
"locale": "th",
"name": "Thai",
"url": "https://zume.training/th"
},
"tur": {
"locale": "tr",
"name": "Turkish",
"url": "https://zume.training/tr"
},
"urd": {
"locale": "ur",
"name": "Urdu",
"url": "https://zume.training/ur"
},
"vie": {
"locale": "vi",
"name": "Vietnamese",
"url": "https://zume.training/vi"
},
"yor": {
"locale": "yo",
"name": "Yoruba",
"url": "https://zume.training/yo"
},
"cmn": {
"locale": "zhcn",
"name": "Chinese (Simplified)",
"url": "https://zume.training/zhcn"
},
"yue": {
"locale": "zhhk",
"name": "Cantonese (Traditional)",
"url": "https://zume.training/zhhk"
}
},
"approx": {
"pbu": {
"locale": "pus",
"lang": "Pashto",
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
"url": "https://zume.training/pus"
},
"pbt": {
"locale": "pus",
"lang": "Pashto",
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
"url": "https://zume.training/pus"
},
"pst": {
"locale": "pus",
"lang": "Pashto",
"note": "Published in Pashto; the publisher does not say which variety, and this group speaks one of three",
"url": "https://zume.training/pus"
}
},
"entries": [
{
"locale": "am",
"name": "Amharic",
"native": "አማርኛ",
"iso": "amh",
"url": "https://zume.training/am",
"listed": true,
"sitemap": true
},
{
"locale": "ar",
"name": "Arabic",
"native": "العربية",
"iso": "arb",
"url": "https://zume.training/ar",
"listed": true,
"sitemap": true
},
{
"locale": "ar_jo",
"name": "Arabic (Jordanian)",
"native": "العربية - الأردن",
"iso": "ajp",
"url": "https://zume.training/ar_jo",
"listed": true,
"sitemap": true
},
{
"locale": "ar_ma",
"name": "Arabic (Moroccan)",
"native": "العربية - الأردن",
"iso": "ary",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "ar_tn",
"name": "Arabic (Tunisian)",
"native": "العربية التونسية",
"iso": "aeb",
"url": "https://zume.training/ar_tn",
"listed": true,
"sitemap": true
},
{
"locale": "asl",
"name": "American Sign Language",
"native": "Sign Language",
"iso": "ase",
"url": "https://zume.training/asl",
"listed": true,
"sitemap": true
},
{
"locale": "az",
"name": "Azerbaijani",
"native": null,
"iso": "azj",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "bg",
"name": "Bulgarian",
"native": "български език",
"iso": "bul",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "bho",
"name": "Bhojpuri",
"native": "भोजपुरी",
"iso": "bho",
"url": "https://zume.training/bho",
"listed": true,
"sitemap": true
},
{
"locale": "bn",
"name": "Bengali (India)",
"native": "বাংলা",
"iso": "ben",
"url": "https://zume.training/bn",
"listed": true,
"sitemap": true
},
{
"locale": "bs",
"name": "Bosnian",
"native": "Bosanski",
"iso": "bos",
"url": "https://zume.training/bs",
"listed": true,
"sitemap": true
},
{
"locale": "de",
"name": "German",
"native": "Deutsch",
"iso": "deu",
"url": "https://zume.training/de",
"listed": true,
"sitemap": true
},
{
"locale": "en",
"name": "English",
"native": null,
"iso": "eng",
"url": "https://zume.training/en",
"listed": true,
"sitemap": true
},
{
"locale": "es",
"name": "Spanish",
"native": "Español",
"iso": "spa",
"url": "https://zume.training/es",
"listed": true,
"sitemap": true
},
{
"locale": "fa",
"name": "Persian/Farsi",
"native": "فارسی",
"iso": "pes",
"url": "https://zume.training/fa",
"listed": true,
"sitemap": true
},
{
"locale": "fo",
"name": "Faroese",
"native": null,
"iso": "fao",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "fr",
"name": "French",
"native": "Français",
"iso": "fra",
"url": "https://zume.training/fr",
"listed": true,
"sitemap": true
},
{
"locale": "gu",
"name": "Gujarati",
"native": "ગુજરાતી",
"iso": "guj",
"url": "https://zume.training/gu",
"listed": true,
"sitemap": true
},
{
"locale": "ha",
"name": "Hausa",
"native": null,
"iso": "hau",
"url": "https://zume.training/ha",
"listed": true,
"sitemap": true
},
{
"locale": "hi",
"name": "Hindi",
"native": "हिन्दी",
"iso": "hin",
"url": "https://zume.training/hi",
"listed": true,
"sitemap": true
},
{
"locale": "hr",
"name": "Croatian",
"native": "Hrvatski",
"iso": "hrv",
"url": "https://zume.training/hr",
"listed": true,
"sitemap": true
},
{
"locale": "hy",
"name": "Armenian",
"native": null,
"iso": "hye",
"url": "https://zume.training/hy",
"listed": true,
"sitemap": true
},
{
"locale": "id",
"name": "Indonesian",
"native": "Bahasa Indonesia",
"iso": "ind",
"url": "https://zume.training/id",
"listed": true,
"sitemap": true
},
{
"locale": "it",
"name": "Italian",
"native": "Italiano",
"iso": "ita",
"url": "https://zume.training/it",
"listed": true,
"sitemap": true
},
{
"locale": "ja",
"name": "Japanese",
"native": "日本語",
"iso": "jpn",
"url": "https://zume.training/ja",
"listed": true,
"sitemap": true
},
{
"locale": "kn",
"name": "Kannada",
"native": "ಕನ್ನಡ",
"iso": "kan",
"url": "https://zume.training/kn",
"listed": true,
"sitemap": true
},
{
"locale": "ko",
"name": "Korean",
"native": "한국어",
"iso": "kor",
"url": "https://zume.training/ko",
"listed": true,
"sitemap": true
},
{
"locale": "ku",
"name": "Kurdish",
"native": "کوردی",
"iso": "ckb",
"url": "https://zume.training/ku",
"listed": true,
"sitemap": true
},
{
"locale": "ky",
"name": null,
"native": null,
"iso": "kir",
"url": "https://zume.training/ky",
"listed": false,
"sitemap": true
},
{
"locale": "lo",
"name": "Lao",
"native": "ພາສາລາວ",
"iso": "lao",
"url": "https://zume.training/lo",
"listed": true,
"sitemap": true
},
{
"locale": "lv",
"name": "Latvian",
"native": "Latviešu",
"iso": "lvs",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "mai",
"name": "Maithili",
"native": "मैथिली",
"iso": "mai",
"url": "https://zume.training/mai",
"listed": true,
"sitemap": true
},
{
"locale": "ml",
"name": "Malayalam",
"native": "മലയാളം",
"iso": "mal",
"url": "https://zume.training/ml",
"listed": true,
"sitemap": true
},
{
"locale": "mn",
"name": "Mongolian",
"native": "Монгол",
"iso": "khk",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "mr",
"name": "Marathi",
"native": "मराठी",
"iso": "mar",
"url": "https://zume.training/mr",
"listed": true,
"sitemap": true
},
{
"locale": "ms",
"name": "Malay",
"native": "Bahasa Malaysia",
"iso": "zlm",
"url": "https://zume.training/ms",
"listed": true,
"sitemap": true
},
{
"locale": "my",
"name": "Burmese",
"native": "မြန်မာဘာသာ",
"iso": "mya",
"url": "https://zume.training/my",
"listed": true,
"sitemap": true
},
{
"locale": "ne",
"name": "Nepali",
"native": "नेपाली",
"iso": "npi",
"url": "https://zume.training/ne",
"listed": true,
"sitemap": true
},
{
"locale": "or",
"name": "Odia",
"native": "ଓଡ଼ିଆ",
"iso": "ory",
"url": "https://zume.training/or",
"listed": true,
"sitemap": true
},
{
"locale": "pa",
"name": "Punjabi",
"native": "ਪੰਜਾਬੀ",
"iso": "pan",
"url": "https://zume.training/pa",
"listed": true,
"sitemap": true
},
{
"locale": "pa_pk",
"name": "Punjabi (Western)",
"native": "ਪੰਜਾਬੀ (ਪੱਛਮੀ)",
"iso": "pnb",
"url": "https://zume.training/pa_pk",
"listed": true,
"sitemap": true
},
{
"locale": "pl",
"name": "Polish",
"native": "Polski",
"iso": "pol",
"url": "https://zume.training/pl",
"listed": true,
"sitemap": true
},
{
"locale": "prs",
"name": null,
"native": null,
"iso": "prs",
"url": "https://zume.training/prs",
"listed": false,
"sitemap": true
},
{
"locale": "pt",
"name": "Portuguese",
"native": "Português",
"iso": "por",
"url": "https://zume.training/pt",
"listed": true,
"sitemap": true
},
{
"locale": "pus",
"name": null,
"native": null,
"iso": null,
"url": "https://zume.training/pus",
"listed": false,
"sitemap": true,
"why": "Pashto has three individual codes in the people-group data (pbu, pbt, pst) and the publisher gives nothing that selects one"
},
{
"locale": "ro",
"name": "Romanian",
"native": "Română",
"iso": "ron",
"url": "https://zume.training/ro",
"listed": true,
"sitemap": true
},
{
"locale": "ru",
"name": "Russian",
"native": "Русский",
"iso": "rus",
"url": "https://zume.training/ru",
"listed": true,
"sitemap": true
},
{
"locale": "sl",
"name": "Slovenian",
"native": "Slovenščina",
"iso": "slv",
"url": "https://zume.training/sl",
"listed": true,
"sitemap": true
},
{
"locale": "so",
"name": "Somali",
"native": "Soomaali",
"iso": "som",
"url": "https://zume.training/so",
"listed": true,
"sitemap": true
},
{
"locale": "swa",
"name": "Swahili",
"native": "Kiswahili",
"iso": "swh",
"url": "https://zume.training/swa",
"listed": true,
"sitemap": true
},
{
"locale": "ta",
"name": "Tamil",
"native": "தமிழ்",
"iso": "tam",
"url": "https://zume.training/ta",
"listed": true,
"sitemap": true
},
{
"locale": "te",
"name": "Telugu",
"native": "తెలుగు",
"iso": "tel",
"url": "https://zume.training/te",
"listed": true,
"sitemap": true
},
{
"locale": "th",
"name": "Thai",
"native": "ไทย",
"iso": "tha",
"url": "https://zume.training/th",
"listed": true,
"sitemap": true
},
{
"locale": "tl",
"name": "Tagalog",
"native": null,
"iso": "tgl",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "tr",
"name": "Turkish",
"native": "Türkçe",
"iso": "tur",
"url": "https://zume.training/tr",
"listed": true,
"sitemap": true
},
{
"locale": "uk",
"name": "Ukrainian",
"native": "Україна",
"iso": "ukr",
"url": null,
"listed": true,
"sitemap": false
},
{
"locale": "ur",
"name": "Urdu",
"native": "اردو",
"iso": "urd",
"url": "https://zume.training/ur",
"listed": true,
"sitemap": true
},
{
"locale": "vi",
"name": "Vietnamese",
"native": "Tiếng Việt",
"iso": "vie",
"url": "https://zume.training/vi",
"listed": true,
"sitemap": true
},
{
"locale": "yo",
"name": "Yoruba",
"native": "Yorùbá",
"iso": "yor",
"url": "https://zume.training/yo",
"listed": true,
"sitemap": true
},
{
"locale": "zhcn",
"name": "Chinese (Simplified)",
"native": "中文(简体)",
"iso": "cmn",
"url": "https://zume.training/zhcn",
"listed": true,
"sitemap": true
},
{
"locale": "zhhk",
"name": "Cantonese (Traditional)",
"native": "中文(繁體,香港)",
"iso": "yue",
"url": "https://zume.training/zhhk",
"listed": true,
"sitemap": true
},
{
"locale": "zhtw",
"name": "Chinese (Traditional)",
"native": "中文(繁體)",
"iso": "cmn",
"url": "https://zume.training/zhtw",
"listed": true,
"sitemap": true
}
]
}

View file

@ -49,7 +49,7 @@ the Tauri origin** — exactly the surprise `capacitor.config.json` documents fo
**The fix is a response header on the token host, not a change in `tauri.conf.json`.**
Measured 2026-09-21: `https://doxa.life/api/maps/token` → **200**;
`https://the-host/api/maps/token` → **404**. A desktop build pointed at the-platform would launch
`https://the-host/api/maps/token` → **404**. A desktop build pointed at the-host would launch
with blank basemaps even though the window opens and the element mounts correctly. Separately, the
Mapbox token is **URL-restricted**: with a `the-host` Referer the style document returns 200 but
every **tile 403s**, while the same token with a `doxa.life` Referer returns 200. Verify the token

View file

@ -28,7 +28,7 @@ Then in wp-admin (http://localhost:8888/wp-admin, admin/password):
1. Plugins → activate **Doxa Maps Embed**.
2. Settings → **Doxa Maps** → set the catalog base to a real served tree, e.g.
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base the forge host).
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base the git forge host).
3. Create a page and add the shortcode:
```

View file

@ -22,7 +22,11 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// BUILD_LOCK_WAIT=600 to QUEUE rather than collide. Note the lock serializes but does NOT
// dedupe: N queued builds = N full rebuilds.
// ★ THE PIPELINE RUNS IN PHASES, NOT AS ONE `&&` CHAIN (2026-09-23). Measured on the build
// box: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were
// box AS IT WAS THAT DAY — the numbers below are a DATED BENCHMARK, not a running total.
// The gate set has grown since (36 steps as of 2026-09-24: 7 pre + 29 post); the timings
// and the "31 in, 31 out" count are kept verbatim because they are the EVIDENCE that the
// phase change preserved the step set. Restating them as current would destroy that.
// Then: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were
// queueing behind check:mjs-parse (26.9s) and check:parse (18.7s), not working. The 22 vite
// builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own
// outDir with emptyOutDir:false, so they only ever needed to not overwrite each other.
@ -36,13 +40,16 @@ The scripts these commands are grounded in (from this branch's `package.json`):
// `&&` BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and
// the build still blocks every gate that reads its output. Within a phase the runner reports
// ALL failures instead of stopping at the first, so three broken gates cost one cycle, not three.
// ⚠ check:quoted-scripts IS ITSELF IN gates:post, so adding or removing ANY gate changes the
// very line this doc quotes — the update is self-referential and takes two passes: wire the
// gate, then re-transcribe. That is the mechanism working, not a bug.
// If you edit package.json, re-transcribe this block — a doc that QUOTES a config is
// claiming to be ground truth, and this one had silently stopped being it.
"dev": "bun x vite",
"build": "bash internal/build-lock.sh",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture",
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
"preview": "bun x vite preview",
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production

View file

@ -71,7 +71,7 @@ apply it. Rule of thumb: **a path with ≥2 directory segments under the tree ro
page → serve it framable.**
Portable ways to implement the split, by host:
- **Traefik** (the-platform/go-base): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
- **Traefik** (the-host/go-base): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
sets `Content-Security-Policy: frame-ancestors *` + clears `X-Frame-Options`. (Live example:
`/data/coolify/proxy/dynamic/doxa-maps-embed.yaml`.)
- **Cloudflare Pages / Netlify**: a `_headers` file — `/<bundle>/*/* Content-Security-Policy: frame-ancestors *`.

View file

@ -110,13 +110,13 @@ MAPBOX_TOKEN=$MAPBOX_TOKEN node internal/embed-smoke.mjs --grep <your-plugin>
> **Do not fetch the token from `https://doxa.life/api/maps/token` here — measured 2026-09-23, it does
> not work for this.** That endpoint serves a token URL-restricted to `doxa.life`, so a tile request
> without that Referer returns **403** and every basemap in your smoke test renders blank while the pins
> draw fine. That is the exact symptom the product owner reported as *"it's loading the pins, but it's not
> draw fine. That is the exact symptom the agent reported as *"it's loading the pins, but it's not
> loading the actual underlying map"*, and it is why `tokenUrl` was emptied in the build config on
> 2026-09-22. This doc used to hand you that command, so an inheritor's first run reproduced the bug.
>
> The `token` field in `vite.doxa-maps-build-config.json` is the one that works: measured, the config
> token returns **200** for a tile request with no Referer while the endpoint token returns **403**. It
> is a `pk.` public key, baked deliberately (product owner ruling 2026-09-21) so a map works on any host —
> is a `pk.` public key, baked deliberately (agent ruling 2026-09-21) so a map works on any host —
> protected by URL restrictions rather than secrecy. `check:public-hygiene` allows `pk.` and fails the
> build on `sk.`/`tk.`, so this is safe to read and never a secret to guard.
@ -178,7 +178,7 @@ The platform serves the catalog directly, so a pushed change is live at once —
(The fuller runbook with every gotcha lives in the project's deploy-cycle notes; the block above is the
load-bearing sequence.)
(The end-state is the the forge CI build-gate — push source, CI builds + publishes automatically. Until a runner
(The end-state is the the git forge CI build-gate — push source, CI builds + publishes automatically. Until a runner
is registered, the runbook is the manual path. Same `build:unlocked` seam.)
## Step 5 — See it live (the 5 deployment methods)

View file

@ -207,11 +207,11 @@ Your built maps are just files. To show them on other websites, the files need a
on the internet that any site can pull from — that's what a "CDN" is. **You choose the home, and
there are options:**
- **Your own site (the source org).** Put the built maps in the source org's `public/` folder — its Nuxt
site serves them, so **the source org is the CDN**. No third party.
- **Your own site (dx-life).** Put the built maps in dx-life's `public/` folder — its Nuxt
site serves them, so **dx-life is the CDN**. No third party.
- **GitHub.** Push the built maps to a public GitHub repo and serve them free through
**GitHub Pages or jsDelivr** — **GitHub is the CDN**.
- **doxa-base's built-in home (the forge).** doxa-base ships **the forge as a CDN** — push the maps
- **doxa-base's built-in home (the git forge).** doxa-base ships **the git forge as a CDN** — push the maps
there and it serves them, with no extra service to set up.
- **Anywhere else.** Any static host works — Cloudflare, an S3-style bucket, etc.
@ -220,7 +220,7 @@ updates everywhere.
**How it's wired** (one file: `vite.doxa-maps-build-config.json`; full field table in
**[reference.md](./reference.md)**): `bun run build` always writes to `public/doxa-maps/<bundle>/`.
Set `paths` to also copy the tree into another host's folder (e.g. the source org's `public/`). Set
Set `paths` to also copy the tree into another host's folder (e.g. dx-life's `public/`). Set
`embedBaseUrl` to the public URL of your chosen home — it generates absolute, paste-anywhere embed
snippets (leave it empty for relative URLs that work wherever the folder is dropped).

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5.
Generated from 19 bundle(s).
Generated from 22 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -49,6 +49,9 @@ Generated from 19 bundle(s).
| `gospel-resources` | `resources-map` | `single` |
| `my-upg-100-list` | `poster-wall` | `single` |
| `my-upg-100-list` | `upg-100-list` | `single` |
| `prayer-walk-planner` | `prayer-walk-planner` | `single` |
| `regions-dashboard` | `region-dashboards` | `single` |
| `regions-dashboard` | `regions-list` | `single` |
| `template-bundle` | `template-map-a` | `single` |
| `template-bundle` | `template-map-b` | `single` |
@ -60,6 +63,7 @@ Generated from 19 bundle(s).
| `doxa-simple-map` | `doxa-simple-map` | `parameterized` |
| `doxa-vision-card` | `doxa-vision-card` | `parameterized` |
| `people-groups-grid` | `people-groups-grid` | `parameterized` |
| `upg-neighbor` | `upg-neighbor` | `parameterized` |
## Capability 3 — multi-view-element (monolithic)
@ -73,8 +77,11 @@ Generated from 19 bundle(s).
| Bundle | Profile | Machine key |
|---|---|---|
| `big-picture` | `big-picture` | `nested-elements-mfe` |
| `country-posters` | `country-posters-mfe` | `nested-elements-mfe` |
| `dashboard-plus-grid` | `dashboard-plus-grid` | `nested-elements-mfe` |
| `gospel-resources` | `gospel-dashboard` | `nested-elements-mfe` |
| `my-upg-100-list` | `upg-100-mfe` | `nested-elements-mfe` |
| `regions-dashboard` | `regions-mfe` | `nested-elements-mfe` |
| `research-map-plus-grid` | `research-map-plus-grid` | `nested-elements-mfe` |
| `stack-demo` | `stack-demo` | `nested-elements-mfe` |

View file

@ -36,7 +36,7 @@ and a change here can break other maps.
`import … from '@map/components/legends/LegendProgressBar.vue'`
- **LegendRows** — (no description)
`import … from '@map/components/legends/LegendRows.vue'`
- **LegendTools** — LegendTools.vue — Fly + Clusters floating toolbar
- **LegendTools** — Does the Fly + Clusters toolbar render for this legend type?
`import … from '@map/components/legends/LegendTools.vue'`
- **SemanticTreeLegend** — SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
`import … from '@map/components/legends/SemanticTreeLegend.vue'`
@ -107,10 +107,16 @@ and a change here can break other maps.
`import … from '@map/composables/poster/useMapPoster.js'`
- **usePosterLayout** — usePosterLayout — slot positioning math for poster compositions.
`import … from '@map/composables/poster/usePosterLayout.js'`
- **printInPlace** — printInPlace.js — SHARED (@map/composables/printInPlace.js).
`import … from '@map/composables/printInPlace.js'`
- **retryableAsyncComponent** — retryableAsyncComponent.js — SHARED (@map/composables/retryableAsyncComponent.js).
`import … from '@map/composables/retryableAsyncComponent.js'`
- **useAvatarGenerator** — Avatar Generator Composable
`import … from '@map/composables/useAvatarGenerator.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'`
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
`import … from '@map/composables/useEdgeScrollAffordance.js'`
- **useGeocoderSearch** — Shared geocoder→map search logic (kind-switch + Mapbox filter builder + the
@ -137,6 +143,8 @@ and a change here can break other maps.
`import … from '@map/composables/useMapSelection.js'`
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
`import … from '@map/composables/useMapTheme.js'`
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
`import … from '@map/composables/usePosterPrints.js'`
- **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator.
`import … from '@map/composables/usePplrInstance.js'`
- **usePrayedLocally** — usePrayedLocally.js — the "my dot glows" encouragement loop.
@ -149,6 +157,8 @@ and a change here can break other maps.
`import … from '@map/composables/useSelectedPin.js'`
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
`import … from '@map/composables/useShadowStyles.js'`
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
`import … from '@map/composables/useSharedMapToken.js'`
### Utilities (pure helper functions)
@ -162,6 +172,8 @@ and a change here can break other maps.
`import … from '@map/utils/NetworkClusteringUtil.js'`
- **apiBaseUrl** — Back-compat shim. The real apiBaseUrl.js lives in the api/ seam folder.
`import … from '@map/utils/apiBaseUrl.js'`
- **countries-meta** — countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
`import … from '@map/utils/countries-meta.js'`
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
`import … from '@map/utils/embedBase.js'`
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
@ -191,6 +203,10 @@ and a change here can break other maps.
### Data (static reference tables)
- **countries** — "value": "AFG",
`import … from '@map/data/countries.json'`
- **country-posters** — "stats": {
`import … from '@map/data/country-posters.json'`
- **iso-numeric-to-alpha2** — "4":"AF","8":"AL","12":"DZ","24":"AO","32":"AR","36":"AU","40":"AT","50":"BD",
`import … from '@map/data/iso-numeric-to-alpha2.json'`
- **langFamilyByIso** — "aar": "Afro-Asiatic",

View file

@ -199,7 +199,7 @@ name comes from `vite.doxa-maps-build-config.json` `name` (default `doxa-maps`).
`emptyOutDir: false` keeps sibling bundles intact when you rebuild one.
**Shadow-style comments are stripped from production bundles.** Component CSS is injected
from `useShadowStyles(\`…\`)` template literals that ship, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
from `useShadowStyles(\`…\`)` template literals that ship verbatim, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
(build only, `enforce: 'pre'`) removes the `/* … */` blocks inside those literals and collapses the blank lines
they leave; a literal with `${…}` is left untouched with a warning, and dev mode keeps every comment.
@ -344,7 +344,7 @@ export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-ma
> diagnosed the blank basemap and then kept prescribing its cause.
>
> The `token` field in `vite.doxa-maps-build-config.json` returns **200** on the identical request
> from any origin. It is a `pk.` public key baked deliberately (product owner ruling 2026-09-21), protected
> from any origin. It is a `pk.` public key baked deliberately (agent ruling 2026-09-21), protected
> by URL restrictions rather than secrecy; `check:public-hygiene` allows `pk.` and fails the build on
> `sk.`/`tk.`. So the SHELL COMMANDS above — the ones that curl a token and hand it to a gate as
> `MAPBOX_TOKEN` — now work where they previously fetched a token that 403s.
@ -424,7 +424,7 @@ shape** — the same one that captioned the product SPA as the plugin-studio.
**`bun run check:quoted-scripts` is the exception that proves the rule above** — it is the one
doc-integrity gate that DOES run in CI (blocking), because it compares two files already in the
checkout: it needs no browser, no network, and no mtime. It asserts that the `scripts` block
quoted in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
quoted verbatim in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
carries its own hand-written plea to re-transcribe after every edit, and drifted anyway — twice.
The second drift omitted `check:public-hygiene` (the secret scan) from the build chain it
documents, which is the dangerous shape: **an omission in a quoted list is invisible**, since
@ -457,7 +457,7 @@ Edit this one file, then `bun run build`.
## Data sources
### ★ Persecution-safety — a rule about WHICH FIELDS a profile may carry
### ★ Hostility-safety — a rule about WHICH FIELDS a profile may carry
Read this before adding a field or a link to any profile. **Some profiles are deliberately kept free of
gospel/church/believer data, and a profile failing a "should have resource links" check may be OBEYING
@ -481,9 +481,9 @@ the same line literally for `cities-internet` means **adding a gospel-resource f
header forbids exactly that**. A requirement that names two subjects deserves a per-subject check before
anyone drains it.
> **"Persecution-safe" is used for three different things in this repo** — don't assume one rule. Here it
> **"Hostility-safe" is used for three different things in this repo** — don't assume one rule. Here it
> means FIELD SEPARATION. In `key-cities/cities-list.vue` it means placename precision ("a wrong placename
> in a sensitive-context-sensitive dataset is the dangerous kind of wrong"). In
> in a safety-sensitive dataset is the dangerous kind of wrong"). In
> `research-dashboard/.../DashboardDataTable.vue` it means a dependency-free table (no third-party grid).
> Only the first governs what data a profile may hold.

View file

@ -28,6 +28,6 @@
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 · the forge (source-control-as-CDN). No lock-in — un-closable primitives.
Capacitor · the git forge (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

@ -0,0 +1,33 @@
<!-- MOVED OUT OF THE SERVED FOLDER, 2026-09-24. This lived at
public/assets/fonts/noto-serif-devanagari/README.md, where check:public-hygiene flagged it
(twice — the source copy and the copy generate-tree stages into the tree) as "documentation in
a served folder", and that finding was red-gating build:unlocked for every builder.
The CONTENT is kept verbatim because it answers a question the files cannot: why this font is
vendored at all, and why 400 and 700 resolve to one variable file.
The font's LICENSE (SIL OFL 1.1) deliberately STAYS beside the woff2 — that one is a
distribution obligation, not documentation, and removing it would breach the licence. -->
# Noto Serif Devanagari — the Devanagari subset only
**Why vendored:** the prayer packet's font stack is Georgia / Palatino Linotype /
serif, none of which carry Devanagari. Measured by rendering Devanagari at 48px
and READING THE PIXELS, Hindi came out as **tofu boxes** — this machine has zero
Devanagari fonts (`fc-list`). A `hi` translation without this file would ship a
page of empty rectangles.
**Licence:** SIL Open Font License 1.1 (see `LICENSE`). Free to bundle and serve.
**Subset:** `U+0900-097F` plus the Devanagari extensions Google's own subsetting
selects — the *devanagari* range ONLY, matching how `poppins/` ships `latin` and
`latin-ext` separately so a page downloads only what it needs. 124 KB, against
6-8 KB for a Latin subset; that is the cost of the script's glyph count, and it
loads only on a Hindi packet.
**Source:** the woff2 Google Fonts serves for
`Noto+Serif+Devanagari:wght@400;700` with a modern browser UA. Note the 400 and
700 faces resolve to the SAME file — it is a variable font, so one file covers
both weights and vendoring two would duplicate 124 KB for nothing.
To refresh: request that CSS with a Chrome user-agent (without one Google serves
unsubsetted TTF), take the `@font-face` block whose `unicode-range` contains
`U+0900`, and download its woff2.

View file

@ -0,0 +1,48 @@
/**
* _serves-this-tree.mjs — a browser gate must prove the server it talks to is THIS build.
*
* THE FAILURE THIS EXISTS FOR, MEASURED. Browser gates here default to
* http://127.0.0.1:4173 and adopt whatever answers 200 on it. On this machine that port was
* held by an unrelated process belonging to a different worktree, and the gospel-resources
* gate spent three runs reporting on a build that was not the one on disk — first a PASS,
* then a mount timeout that looked exactly like a regression in code that had nothing to do
* with it. A gate that can report on the wrong tree is worse than no gate: it produces
* confident findings about code nobody changed, and hides real ones.
*
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
*
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
* build and which names every bundle and profile in the tree. If the bytes served differ
* from the bytes on disk, it is a different build — a different worktree, or a stale server
* someone forgot to restart, both of which produce the same lie.
*
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
* instead, or to skip. What it must never do is quietly continue.
*/
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
const MANIFEST = 'doxa-maps/manifest.json'
/**
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
* @param {string} repo absolute path to the repo root
* @returns {Promise<{ok:boolean, why:string}>}
*/
export async function servesThisTree(base, repo) {
let local
try {
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
} catch {
return { ok: false, why: `no local public/${MANIFEST} to compare against — build the tree first` }
}
const root = String(base).replace(/\/+$/, '').replace(/\/doxa-maps$/, '')
const r = await fetch(`${root}/${MANIFEST}`).catch(() => null)
if (!r || !r.ok) return { ok: false, why: `nothing serving ${MANIFEST} at ${root}` }
const served = createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex')
if (served !== local) {
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
}
return { ok: true, why: 'same build' }
}

View file

@ -19,7 +19,7 @@ const API = 'https://pray.doxa.life/api/people-groups/list?fields=name,slug,wagf
// ── mirror of people-groups-grid.vue applyDropdownFilters + the Fuse-ish search keys ──────
// Dropdown axes match by VALUE (u[field].value === selection) — the exact contract the header
// dropdown -> grid prop coupling relies on. Country is excluded per product owner.
// dropdown -> grid prop coupling relies on. Country is excluded per agent.
function dropdownFilter(recs, { wagfRegion = '', wagfBlock = '', rop1 = '', religion = '' } = {}) {
let out = recs
for (const [f, sel] of Object.entries({ wagf_region: wagfRegion, wagf_block: wagfBlock, rop1, religion })) {

View file

@ -18,7 +18,7 @@
* API. Camera motion in particular (fitBounds/flyTo) has NO API equivalent to mirror; the only
* way to prove "select rows, the map flies to the selection" is to select rows and read the map.
*
* ── THE COMBINATION MATRIX (product owner: "EVERY SINGLE COMBINATION ... log whether it DISPLAYED,
* ── THE COMBINATION MATRIX (agent: "EVERY SINGLE COMBINATION ... log whether it DISPLAYED,
* pass/fail only") ─────────────────────────────────────────────────────────────────────────
* One line per combination, BOTH halves recorded, pass/fail only. "Displayed" is never inferred
* from a click firing or an element existing — see WHAT COUNTS AS DISPLAYED per pair below.
@ -48,13 +48,13 @@
* and requires the harness to FAIL, NAMING the broken combination. `--self-test-mode=camera-freeze`
* poisons useSelectionCamera's target map instead, reproducing "the grid->map link stops moving
* the camera" and requires THAT combination named. A harness that has only ever reported PASS is
* unproven (tracked issue): "I made it fail" is not enough
* unproven (LL-negative-test-the-regression-not-the-feature): "I made it fail" is not enough
* evidence — the question is whether it fails on the SAME defect shape the real bug had.
*
* PRECONDITIONS, checked and SKIPPED (exit 0) rather than reported as a defect when unmet:
* - the tree must be SETTLED (internal/settled-tree-gate.mjs) — a build mid-flight makes any
* browser gate that reads it report a confident, false diagnosis
* (tracked issue).
* (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
* - the live API (pray.doxa.life) must be reachable.
*
* USAGE

View file

@ -7,7 +7,7 @@
# this makes the collision STRUCTURALLY impossible: the FIRST build acquires the lock; a SECOND build that
# tries while it's held FAILS FAST with a clear message instead of corrupting the tree.
#
# This is the local-dev form of IDEA-FORGEJO-CICD-BUILD-LOCK-MULTIWRITER (the CI/CD concurrency mutex) — the
# This is the local-dev form of the CI/CD build-lock design (the CI/CD concurrency mutex) — the
# swarm hit the hazard before that gate, so the primitive is built now. Same contract: first-wins, second
# fails clean, no race.
#

View file

@ -34,6 +34,7 @@ import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { servesThisTree } from './_serves-this-tree.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
@ -45,6 +46,20 @@ if (!existsSync(MANIFEST)) {
process.exit(0)
}
// ★ The server has to be serving THIS checkout, not merely answering. 4173 on this machine
// was held by an unrelated process from another worktree, and a gate that adopts it reports
// a confident result about a build nobody here changed. A pass earned against the wrong tree
// is worse than no pass.
{
const tree = await servesThisTree(BASE, ROOT)
if (!tree.ok) {
console.error('A11Y-BUTTON-NAMES: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${tree.why}`)
console.error(' Point BASE at your own server (BASE=http://127.0.0.1:PORT/doxa-maps/).')
process.exit(2)
}
}
let tk = ''
try {
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })

View file

@ -0,0 +1,159 @@
#!/usr/bin/env node
// check-aria-labels-localized — no control may announce a RAW I18N KEY, and the
// accessible names must change with the locale.
//
// ★ THE ONE STRING A SIGHTED TESTER NEVER SEES. Every other untranslated string in
// this tree got caught because somebody looked at the screen. aria-label is only ever
// spoken aloud, so it survived every visual QA pass. Measured 2026-09-24 on the served
// upg-100-list leaf: the legend's toggle button announced the literal string
// "aria.toggleLegend" — in English too, not just translated locales — because vue-i18n
// ECHOES THE KEY when a lookup misses and `toggleLegend` was absent from aria.json
// while its two siblings (backToLegend, closeDetailView) were present. Six call sites
// across LegendDesktop.vue and LegendMobile.vue, desktop AND mobile, every locale.
//
// A second instance in the same sweep had the opposite shape: SemanticTreeLegend's
// collapse button hardcoded 'Collapse legend' / 'Expand legend' with no t() at all, so
// it was correct English and untranslatable. One key missing FROM the catalog, one
// string never routed TO it — the gate below catches both, because it compares what is
// ANNOUNCED across locales rather than reading the source.
//
// ⚠ TWO ASSERTIONS, NOT ONE. "No raw keys" alone passes a hardcoded English label, and
// "labels differ across locales" alone passes a raw key (a key is identical in every
// locale — which is exactly why the SEPARABILITY floor below is per-locale).
//
// ⚠ A RAW KEY HAS A SHAPE NO HUMAN LABEL HAS: dotted, camelCase, no spaces. That is the
// whole detector, and it is locale-independent — it works on Arabic and Chinese pages
// where no English word list could.
//
// Usage: node internal/check-aria-labels-localized.mjs <leafUrl> [locales]
import { chromium } from 'playwright'
const URL_ = process.argv[2] || process.env.ARIA_URL
if (!URL_) {
console.log('ARIA-LABELS-LOCALIZED: SKIP — no leaf url given.')
console.log(' Pass one, or set ARIA_URL. Serve the TREE ROOT (public/) and point at a leaf,')
console.log(' e.g. doxa-maps/my-upg-100-list/upg-100-list/index.html')
process.exit(0)
}
const UNDER_TEST = (process.argv[3] || 'es,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
// A raw vue-i18n key: starts lowercase, dotted, no whitespace. Human labels have spaces
// ("Zoom in") or are non-Latin; neither matches.
const RAW_KEY = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/
// How many accessible names MUST change when the locale changes. Measured on the
// upg-100-list leaf 2026-09-24: 6 of 13, desktop and mobile alike. The rest are map-
// engine controls (Mapbox's own zoom/fullscreen buttons) whose labels this tree does
// not own. Override for a leaf with a different control set.
const LOCALIZED_FLOOR = Number(process.env.ARIA_LOCALIZED_FLOOR || 6)
async function readLabels(browser, lang, viewport, mobile) {
const page = await browser.newPage({ viewport, isMobile: mobile, hasTouch: mobile })
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(`${URL_}?lang=${lang}`, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(9000)
const labels = await page.evaluate(() => {
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
return out
}
// Shadow-piercing: these controls live several shadow roots deep.
return walk(document)
.filter((e) => e.getAttribute && e.getAttribute('aria-label') !== null)
.map((e) => (e.getAttribute('aria-label') || '').trim())
})
await page.close()
return { labels, errs }
}
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const VIEWPORTS = [
{ name: 'desktop', viewport: { width: 1500, height: 1000 }, mobile: false },
{ name: 'mobile', viewport: { width: 390, height: 844 }, mobile: true },
]
const seen = {}
try {
for (const vp of VIEWPORTS) {
seen[vp.name] = {}
for (const loc of ['en', ...UNDER_TEST]) {
seen[vp.name][loc] = await readLabels(browser, loc, vp.viewport, vp.mobile)
}
}
} finally {
await browser.close()
}
const fails = []
const report = []
for (const vp of VIEWPORTS) {
const byLoc = seen[vp.name]
const en = byLoc.en
// PRECONDITION: a page with no aria-labels at all makes every assertion vacuous. A
// bundle that failed to execute renders nothing with zero page errors (the stale-SRI
// signature), and would otherwise read as "nothing untranslated".
if (!en.labels.length) {
fails.push(`${vp.name}: no aria-labels rendered at all — either the wrong url, or the `
+ 'bundle did not execute (a stale SRI hash refuses the script SILENTLY; run '
+ '`bun run tree` after `BUNDLE=<n> bun x vite build`)')
continue
}
for (const loc of ['en', ...UNDER_TEST]) {
const s = byLoc[loc]
// ASSERTION 1 — NO RAW KEYS, in every locale INCLUDING English. A missing catalog
// entry is announced as its own key name, which is gibberish to a screen reader.
const raw = [...new Set(s.labels.filter((t) => RAW_KEY.test(t)))]
if (raw.length) {
fails.push(`${vp.name}/${loc}: ${raw.length} control(s) announce a RAW I18N KEY — `
+ `${JSON.stringify(raw)} — vue-i18n echoes the key when the lookup misses, so a `
+ 'screen reader reads the key name aloud')
}
// NEVER BLANK either: an empty aria-label is worse than none, it suppresses the
// fallback to the element's own text.
const blank = s.labels.filter((t) => !t).length
if (blank) fails.push(`${vp.name}/${loc}: ${blank} EMPTY aria-label(s)`)
if (s.errs.length) fails.push(`${vp.name}/${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
}
// ASSERTION 2 — THE LOCALE MUST MOVE THEM. Raw-key-free is not the same as localized:
// a hardcoded English label passes assertion 1 forever. At least one accessible name
// must differ from English in each locale, or nothing on this surface is translated.
for (const loc of UNDER_TEST) {
const s = byLoc[loc]
const diff = s.labels.filter((t, i) => en.labels[i] && t !== en.labels[i]).length
if (s.labels.length && diff === 0) {
fails.push(`${vp.name}/${loc}: NOT ONE of ${s.labels.length} aria-label(s) differs from `
+ 'English — the accessible names are hardcoded, not catalogued')
}
// ⚠ A FLOOR, NOT JUST "> 0". `diff === 0` alone is decoration: it prints a count and
// only fails when EVERY label regresses. Measured 2026-09-24 — hardcoding ONE label
// back to English dropped this from 6 to 5 and the gate still passed, which is the
// exact shape ("a number printed but not asserted on") this tree keeps rediscovering.
// The floor is the count that currently holds, so any single regression goes red.
// RAISE IT when you localize more; never lower it to make a red build green.
if (s.labels.length && diff < LOCALIZED_FLOOR) {
fails.push(`${vp.name}/${loc}: only ${diff} of ${s.labels.length} aria-label(s) differ from `
+ `English, expected at least ${LOCALIZED_FLOOR} — a label that used to be catalogued `
+ 'has been hardcoded back to English (it leaves no raw key behind, so assertion 1 '
+ 'cannot see it)')
}
report.push(` ${vp.name.padEnd(8)} ${loc.padEnd(3)}: ${diff}/${s.labels.length} differ from English, `
+ `${s.labels.filter((t) => RAW_KEY.test(t)).length} raw key(s), ${s.labels.filter((t) => !t).length} blank`)
}
}
console.log('ARIA-LABELS-LOCALIZED')
console.log(` leaf : ${URL_}`)
console.log(` labels : ${seen.desktop?.en?.labels.length ?? 0} desktop / ${seen.mobile?.en?.labels.length ?? 0} mobile (English)`)
for (const line of report) console.log(line)
if (fails.length) {
console.error(`\nARIA-LABELS-LOCALIZED: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nARIA-LABELS-LOCALIZED: PASS — no control announces a raw i18n key, none is blank, '
+ 'and choosing a locale changes what a screen reader says.')

View file

@ -27,7 +27,10 @@
* to the enum; 130/130 preserved, 0 lost. The explanation was never the problem — putting it in the
* field that tooling reads was.
*
* WHAT IT ASSERTS: every card's `status` is one of todo / doing / in-progress / blocked / done.
* WHAT IT ASSERTS: every card's `status` is ONE WORD from a closed set — the five workflow
* states plus four terminal ones (superseded / deferred / decided / verified-already-true).
* The point is not the particular words: it is that `status` stays machine-readable so the
* open-count is right. Prose belongs in fields.status_detail.
*
* It deliberately does NOT require `status_detail` to exist. Most cards need no explanation, and
* demanding one would turn a real check into paperwork that gets satisfied with filler.
@ -41,7 +44,23 @@ import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const QA = join(ROOT, '../internal-tracker')
const PHASES = ['01-ideation-research', '02-builder-cycle', '03-deployment-cycle']
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done'])
// ★ THE ENUM WAS TOO NARROW, not the cards wrong. MEASURED 2026-09-24 across 633 cards:
// EIGHT distinct status values are in use and EVERY ONE IS A SINGLE WORD — there is no
// prose left anywhere in the field. The nine this gate was failing carried legitimate
// TERMINAL states the original enum simply predated: superseded (5), deferred (2),
// decided, verified-already-true.
//
// This gate exists to keep the AGGREGATE honest — "a prose status makes every open-count
// wrong while each card still READS correctly". A one-word terminal state serves that
// purpose perfectly; it is exactly what the 130-card migration was FOR. Rewriting nine
// accurate statuses to 'done' would have destroyed real distinctions (a SUPERSEDED card
// was replaced by another; a DEFERRED one was consciously postponed; neither was
// finished) to satisfy a list — losing information to make a checker green.
//
// OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
// open = todo + doing + in-progress (66), closed = done + these four (507), blocked (60).
const TERMINAL_EXTRA = ['superseded', 'deferred', 'decided', 'verified-already-true']
const ALLOWED = new Set(['todo', 'doing', 'in-progress', 'blocked', 'done', ...TERMINAL_EXTRA])
if (!existsSync(QA)) {
// internal-tracker lives outside every repo the CI clones, so absence is EXPECTED there. Say so loudly

View file

@ -53,8 +53,8 @@ const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
// defect recur unnoticed in a second place.
//
// NOT COVERED, and it cannot be: the /plugin-designer SKILL carries the same stale citation and
// lives outside this repo, so no gate here can reach it. That is why the fix is a product owner edit; the
// repo's job is to hold the checkable half (see a tracked issue).
// lives outside this repo, so no gate here can reach it. That is why the fix is a agent edit; the
// repo's job is to hold the checkable half (see BUILD-PLUGIN-DESIGNER-SKILL-MEANS-REAL-WORK).
const DOC_DIR = join(ROOT, 'documentation')
const DOC_FILES = existsSync(DOC_DIR)
? readdirSync(DOC_DIR).filter(f => f.endsWith('.md')).map(f => join(DOC_DIR, f))

View file

@ -0,0 +1,117 @@
// check-card-deck — a DECK must print as CARDS, not as paper.
//
// THE BUG THIS EXISTS FOR: the previous deck used @page{size:letter portrait} with
// height:100% per card, so one card stretched across a whole 8.5x11 sheet — 95
// mostly-empty letter pages, and no photo at all. "The print dialog needs to show
// all the cards not in a paper format but a CARD format."
//
// ⚠ IT ASSERTS THE MEASURED MediaBox, NOT THE CSS. Chromium SILENTLY falls back to
// letter for a named size it does not know: `size: A6 portrait` yields 612x792pt
// with no error, while `size: 2.5in 3.5in` yields exactly 180x252pt. A gate that
// grepped the stylesheet would pass on a deck that prints as letter paper.
//
// Usage: node internal/check-card-deck.mjs [lang] [stock]
// PACKET_ORIGIN (default http://127.0.0.1:4898) · PACKET_API · PACKET_ISO3
import { chromium } from 'playwright'
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const API = process.env.PACKET_API || 'https://pray.doxa.life'
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
const LANG = process.argv[2] || 'en'
const STOCK = process.argv[3] || 'poker'
// Expected MediaBox in points, derived from the stock's inches (72pt = 1in).
// a6 is 4.13x5.83in, the inch rounding of 105x148mm -> ~297.4x419.8pt. An earlier
// constant here said 396.5x559.7 and FAILED a correct render: the gate's own
// arithmetic was wrong, not the renderer. Derive, do not hand-copy.
const IN = { poker: [2.5, 3.5], postcard: [4, 6], a6: [4.13, 5.83] }[STOCK]
if (!IN) { console.error(`unknown stock "${STOCK}"`); process.exit(2) }
const EXPECT = IN.map(v => +(v * 72).toFixed(2))
const b = await chromium.launch()
const pg = await b.newPage({ viewport: { width: 900, height: 1200 } })
const errs = []; pg.on('pageerror', e => errs.push(String(e).slice(0, 140)))
await pg.goto(ORIGIN + '/')
const out = await pg.evaluate(async ({ ORIGIN, API, ISO3, LANG, STOCK }) => {
const [{ createCardRenderer }, strings, norm] = await Promise.all([
import(`${ORIGIN}/library/packet/card-render.js`),
fetch(`${ORIGIN}/library/scripts/posters/packet-strings.json`).then(r => r.json()),
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
])
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url,primary_language&lang=en`, { cache: 'no-store' })
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
const groups = recs.filter(r => String(r.country_code) === ISO3)
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG))
const R = createCardRenderer({ strings, lang: LANG, stock: STOCK })
return { html: R.buildDeck({ id: ISO3.toLowerCase(), label: ISO3 }, groups), n: groups.length }
}, { ORIGIN, API, ISO3, LANG, STOCK })
await pg.setContent(out.html, { waitUntil: 'load' })
await pg.waitForTimeout(4000)
const r = await pg.evaluate(() => {
const cards = [...document.querySelectorAll('.card')]
const imgs = [...document.querySelectorAll('img.ph')]
const qrs = [...document.querySelectorAll('.qr svg')]
// Does the QR carry a real symbol, or an empty path?
const qrPaths = qrs.map(s => (s.querySelector('path')?.getAttribute('d') || '').length)
return {
cards: cards.length,
imgs: imgs.length,
broken: imgs.filter(i => !i.complete || i.naturalWidth === 0).length,
nophoto: document.querySelectorAll('.ph-none').length,
qrs: qrs.length,
qrEmpty: qrPaths.filter(n => n < 50).length,
urls: [...document.querySelectorAll('.url')].slice(0, 2).map(e => e.textContent),
// ⚠ ASSERT THE VALUES ARE HUMAN-READABLE, not merely present. The first deck
// rendered "LAO" / "thm" / "EAN" because normalizeRecords() flattens each
// { value, label } dict to its VALUE and parks the label under <field>_label.
// A gate counting rows passed it: the rows were there, just unreadable.
// An all-caps or all-lowercase token of 2-4 chars with no space is a code.
codeish: [...document.querySelectorAll('.v')]
.map(e => (e.textContent || '').trim())
.filter(v => /^[A-Z]{2,4}$|^[a-z]{2,4}$/.test(v)),
cta: (document.querySelector('.cta') || {}).textContent || '',
overflow: cards.filter(c => c.scrollHeight > c.clientHeight + 2).length,
}
})
const pdf = await pg.pdf({ preferCSSPageSize: true, printBackground: true })
await b.close()
const raw = pdf.toString('latin1')
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m => [+m[1], +m[2]])
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
const [pw, ph] = boxes[0] || [0, 0]
const fails = []
if (!r.cards) fails.push('no cards rendered')
if (pages !== r.cards) fails.push(`${pages} PDF pages for ${r.cards} cards — one card must be exactly one page`)
if (Math.abs(pw - EXPECT[0]) > 2 || Math.abs(ph - EXPECT[1]) > 2) {
fails.push(`page is ${pw}x${ph}pt, expected ${EXPECT[0]}x${EXPECT[1]}pt for "${STOCK}"`
+ (Math.abs(pw - 612) < 2 ? ' — this is LETTER: the @page size silently fell back' : ''))
}
if (r.broken) fails.push(`${r.broken} broken portrait(s)`)
if (!r.imgs && !r.nophoto) fails.push('no portraits at all — a prayer card needs a face')
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR codes for ${r.cards} cards`)
if (r.qrEmpty) fails.push(`${r.qrEmpty} QR code(s) render an empty symbol`)
if (r.overflow) fails.push(`${r.overflow} card(s) overflow their own box`)
if (r.codeish.length) {
fails.push(`${r.codeish.length} field(s) show a raw CODE instead of a label `
+ `(e.g. ${[...new Set(r.codeish)].slice(0, 4).join(', ')}) — read <field>_label`)
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` stock / lang : ${STOCK} / ${LANG}`)
console.log(` cards / PDF pages: ${r.cards} / ${pages}`)
console.log(` page size : ${pw} x ${ph} pt (${(pw / 72).toFixed(2)} x ${(ph / 72).toFixed(2)} in)`)
console.log(` portraits : ${r.imgs} (${r.broken} broken, ${r.nophoto} placeholder)`)
console.log(` QR codes : ${r.qrs} (${r.qrEmpty} empty)`)
console.log(` sample URL : ${r.urls[0] || '—'}`)
console.log(` call to action : ${r.cta}`)
console.log(` raw codes shown : ${r.codeish.length}${r.codeish.length ? ' (' + [...new Set(r.codeish)].slice(0,4).join(', ') + ')' : ''}`)
console.log(` overflowing cards: ${r.overflow}`)
if (fails.length) {
console.error(`\nCARD-DECK: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nCARD-DECK: PASS — ${r.cards} cards, one card per ${(pw / 72).toFixed(2)}x${(ph / 72).toFixed(2)}in page.`)

View file

@ -3,7 +3,7 @@
* check-catalog-identity.mjs — prove the LIVE server is serving the CATALOG's exact bytes.
*
* THE QUESTION THIS ANSWERS, AND WHY THE OBVIOUS CHECK CANNOT ANSWER IT
* Card 1 (tracked issue) asks that ONE public the forge repo be the live
* Card 1 (the public-pipeline design) asks that ONE public the git forge repo be the live
* catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null
* -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered:
* · https://the-host/app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
@ -22,7 +22,7 @@
* additionally require that it matches a cloned catalog checkout.
*
* MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the-platform serves.
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the-host serves.
*
* USAGE
* bun run check:catalog-identity # live public door vs its own manifest

View file

@ -2,7 +2,7 @@
/**
* check-csv-export-shape.mjs — the exported CSV must OPEN correctly in a spreadsheet.
*
* THE DEFECT THIS CATCHES (product owner-reported 2026-09-22: "this is me exporting the CSV directly without
* THE DEFECT THIS CATCHES (agent-reported 2026-09-22: "this is me exporting the CSV directly without
* making any edits... it does look a little bit glitched out"). The generator emitted a one-column
* provenance comment ABOVE the header:
* line 0 ... 1 column "# Research Dashboard export — scope: ..."
@ -13,7 +13,7 @@
*
* ★ WHAT WAS *NOT* WRONG, AND MUST NOT BE "FIXED" ON SUSPICION — three plausible theories, all
* refuted by measurement before a line was changed:
* - "it is tab-separated" — FALSE. The generator joins with commas; the tabs in the product owner's paste
* - "it is tab-separated" — FALSE. The generator joins with commas; the tabs in the agent's paste
* were his terminal rendering, not the bytes.
* - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and
* doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip.
@ -122,7 +122,7 @@ const first = parsed[0] || []
if (first.length === 1 && String(first[0]).startsWith('#')) {
fail('line 0 is a one-column "#" comment, so a spreadsheet will use it AS THE HEADER.',
' The scope sentence becomes the column names and the real header becomes data row 1 — which is\n' +
' what the product owner saw as a (project requirement) export. Put the provenance line LAST; a trailing one-column\n' +
' what the agent saw as a "glitched" export. Put the provenance line LAST; a trailing one-column\n' +
' row is harmless, a leading one destroys the column mapping for the whole sheet.')
}
if (first[0] !== cols[0]) {

View file

@ -0,0 +1,148 @@
// check-deck-click — click the REAL Card Deck button and read what it prints.
//
// Every other packet/card gate drives the RENDERERS directly. This one drives the
// UI: it finds the button in the shadow root, clicks it, and captures the document
// that reaches the print frame. That is the only path that proves the wiring —
// records provider, stock picker, language, shared composable, print frame — is
// connected end to end. A renderer can be perfect while the button that calls it
// passes the wrong stock, or nothing at all.
//
// ⚠ IT INTERCEPTS THE PRINTED HTML rather than reading the iframe back.
// openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the
// frame self-removes on afterprint — reading it back measures a frame that has
// already gone and reports 0 cards for a correct deck.
//
// Usage: node internal/check-deck-click.mjs <wallUrl> [stockId] [expectedPage]
import { chromium } from 'playwright'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const URL_ = process.argv[2]
if (!URL_) { console.error('usage: check-deck-click.mjs <wallUrl> [stock] [expectedPage]'); process.exit(2) }
const STOCK = process.argv[3] || ''
const EXPECT_PAGE = process.argv[4] || (STOCK === 'pokerLandscape' ? '3.5in 2.5in' : '2.5in 3.5in')
// ★ THE LABEL COMES FROM THE CATALOG FOR THIS PAGE'S LOCALE, never a hardcoded
// English string. This matched /card deck/i, so it could only ever run in English:
// pointed at ?lang=es it reported "no Card Deck button" and listed 28 buttons all
// reading "Baraja de tarjetas" — the UI correctly translated, the GATE unable to
// see it. The card deck is a core prayer artefact and it had ZERO coverage in the
// ten locales where a rendering bug is most likely.
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
const localeOf = (u) => { try { return new URL(u).searchParams.get('lang') || 'en' } catch { return 'en' } }
const deckLabel = (loc) => {
const f = join(REPO, 'library/i18n/locales', loc, 'buttons.json')
if (!existsSync(f)) return null
try { return JSON.parse(readFileSync(f, 'utf8')).cardDeck || null } catch { return null }
}
const LOC = localeOf(URL_)
const WANT_DECK = deckLabel(LOC) || deckLabel('en') || 'Card Deck'
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const pg = await b.newPage({ viewport: { width: 1280, height: 1000 } })
const errs = []
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
await pg.goto(URL_, { waitUntil: 'load', timeout: 45000 })
await pg.waitForTimeout(9000)
await pg.evaluate((want) => { window.__WANT_DECK = want }, WANT_DECK)
await pg.evaluate(() => {
window.__printed = ''
const real = document.createElement.bind(document)
document.createElement = (t) => {
const el = real(t)
if (t === 'iframe') {
Object.defineProperty(el, 'contentDocument', {
get: () => ({ open() {}, close() {}, write(h) { window.__printed += h } }),
})
Object.defineProperty(el, 'contentWindow', {
get: () => ({ addEventListener() {}, focus() {}, print() {} }),
})
}
return el
}
})
const r = await pg.evaluate(async (stock) => {
const host = [...document.querySelectorAll('*')]
.find((e) => e.shadowRoot && e.shadowRoot.querySelector('.pbtn'))
if (!host) return { error: 'no poster wall found on this page' }
const sr = host.shadowRoot
const btns = [...sr.querySelectorAll('.pbtn')]
const deck = btns.find((x) => (x.textContent || '').trim().includes(window.__WANT_DECK))
if (!deck) return { error: 'no Card Deck button', buttons: btns.map((x) => x.textContent.trim()) }
if (stock) {
const sel = sr.querySelector('.pstock')
if (!sel) return { error: 'no card-stock picker to drive' }
sel.value = stock
sel.dispatchEvent(new Event('change', { bubbles: true }))
await new Promise((res) => setTimeout(res, 400))
}
deck.click()
await new Promise((res) => setTimeout(res, 9000))
const doc = new DOMParser().parseFromString(window.__printed, 'text/html')
return {
cards: doc.querySelectorAll('.card').length,
qrs: doc.querySelectorAll('.qr svg').length,
imgs: doc.querySelectorAll('img.ph').length,
noph: doc.querySelectorAll('.ph-none').length,
page: (window.__printed.match(/@page\{size:([^;}]*)/) || [])[1] || '',
}
}, STOCK)
await b.close()
if (r.error) {
console.error(`DECK-CLICK: FAIL — ${r.error}`
+ (r.buttons ? ` (buttons present: ${r.buttons.join(' | ')})` : ''))
process.exit(1)
}
const fails = []
if (!r.cards) fails.push('the click printed no cards at all — the button is not wired to the renderer')
if (r.page.trim() !== EXPECT_PAGE) {
fails.push(`@page size is "${r.page.trim()}", expected "${EXPECT_PAGE}"`
+ (STOCK ? ' — the stock picker is not reaching the renderer' : ''))
}
if (r.qrs !== r.cards) fails.push(`${r.qrs} QR code(s) for ${r.cards} cards`)
// ── "116 PORTRAITS" WAS PRINTED AND NEVER ASSERTED ──────────────────────────
// A prayer card without a face is a data tile. This gate collected the count,
// displayed it, and checked only the QR codes — so a deck of 116 cards with ZERO
// portraits passed. That is not hypothetical: card-render.js records that the deck
// once shipped with no photos at all, because image_url was never requested in
// fetchListRecords (measured 95/95 groups DO have a portrait, so the omission cost
// every card its picture).
//
// ⚠ THE COUNT CANNOT BE PINNED, for the same reason the packet's could not: roughly
// half the people groups have no portrait, and the CDN drops some. So two invariants
// instead of a number:
// 1. every card is one shape or the other — portraits + typographic === cards.
// A card that is NEITHER means the renderer emitted a third nobody checks.
// 2. a deck drawn from records that carry image_url must have SOME portraits.
// DECK_EXPECT_PORTRAITS=0 declares a deliberately photo-less deck out loud,
// rather than leaving "none" indistinguishable from "silently none".
if (r.cards && r.imgs + r.noph !== r.cards) {
fails.push(`${r.cards} cards but ${r.imgs} portrait + ${r.noph} typographic = ${r.imgs + r.noph}`
+ ' — every card must be exactly one of the two')
}
if (r.cards && process.env.DECK_EXPECT_PORTRAITS !== '0' && r.imgs === 0) {
fails.push(`${r.cards} cards and NOT ONE portrait — a prayer card without a face is a data tile. `
+ 'This is what a missing image_url in the records provider looks like; set '
+ 'DECK_EXPECT_PORTRAITS=0 to declare a deliberately photo-less deck.')
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` locale : ${LOC} (matched ${JSON.stringify(WANT_DECK)})`)
console.log(` stock : ${STOCK || '(default)'}`)
console.log(` cards printed : ${r.cards} (${r.imgs} portraits, ${r.noph} typographic, ${r.qrs} QR codes)`)
console.log(` @page size : ${r.page.trim()} (want ${EXPECT_PAGE})`)
console.log(` page errors : ${errs.length}`)
if (fails.length) {
console.error(`\nDECK-CLICK: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nDECK-CLICK: PASS — the real button printed ${r.cards} cards at ${r.page.trim()}.`)

View file

@ -3,7 +3,7 @@
* check-doc-quotes-scripts.mjs — a doc that QUOTES package.json must still match it.
*
* THE DEFECT THIS CATCHES, found 2026-09-23. documentation/README-SECTION-3-cli-commands.md
* transcribes the package.json scripts so a reader can see them without opening the file.
* transcribes the package.json scripts verbatim so a reader can see them without opening the file.
* That is a genuinely useful doc and a copy that can never stay true on its own: `bun run serve` was
* changed from `python3 -m http.server` to `node internal/serve-strict.mjs` two commits earlier, and
* the doc still taught the old one. An inheritor following the CLI reference would have started the

View file

@ -59,19 +59,18 @@ const BACKLOGS = [
]
// Pre-existing, from before the rule. Verified 2026-09-21. SHRINK ONLY.
// ★ THE BASELINE IS NOW EMPTY, and that is the strongest state this gate can be in.
// All ten exemptions had since gained evidence — the gate reported every one of them
// as STALE on 2026-09-24 — so the list was pure dead weight: ten ids that exempted
// nothing while implying ten cards still owed a record.
// An EMPTY set is not an unset one: every done card must now carry evidence, with no
// exemptions, and the gate fails outright for a single card that does not. Keeping
// `{ ... }` full of satisfied ids would mean the same today and quietly permit a
// regression the moment someone "reused" an entry.
// If a genuinely un-reconstructable card ever needs an exemption, add its id here WITH
// the reason — and the gate will tell you the day it becomes stale.
const BASELINE = new Set([
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
'a tracked issue',
])
let checked = 0, done = 0
const offenders = []
const staleBaseline = []
@ -87,7 +86,19 @@ for (const rel of BACKLOGS) {
done++
const hasBluf = String(c.bluf || '').trim().length > 0
const hasFields = c.fields && Object.keys(c.fields).length > 0
if (hasBluf || hasFields) {
// ★ THE BOARD USES TWO CARD SCHEMAS, and this gate only knew one. Older cards carry
// `bluf` + `fields`; a second family carries `one_line` (what was done), `why`, and
// `verify` (the artifact and how it was checked). MEASURED 2026-09-24 across 443 done
// cards: 40 carry `verify` and 41 carry `one_line`, and ALL THREE cards this gate was
// failing had one or both — with named probes, assertion counts and negative tests.
// They were among the best-evidenced cards on the board.
// `verify` is if anything the STRONGER signal: it names how the claim was checked,
// which is exactly what "evidence" means here. Demanding a redundant `bluf` alongside
// it would have taught the next agent to pad the field the checker reads rather than
// write the record — the gate training the behaviour it exists to prevent.
const hasVerify = String(c.verify || '').trim().length > 0
const hasOneLine = String(c.one_line || '').trim().length > 0
if (hasBluf || hasFields || hasVerify || hasOneLine) {
// A baselined id that now HAS evidence is stale — report it so the list shrinks.
if (BASELINE.has(c.id)) staleBaseline.push(`${c.id} (${rel})`)
continue
@ -104,13 +115,15 @@ for (const s of staleBaseline) {
}
if (offenders.length) {
console.error(`\nDONE-EVIDENCE: FAIL — ${offenders.length} card(s) are done with NO bluf and NO fields:`)
console.error(`\nDONE-EVIDENCE: FAIL — ${offenders.length} card(s) are done with NO evidence in any field:`)
for (const o of offenders) console.error(` ✗ ${o}`)
console.error(
'\n Add ONE of: a `bluf` saying what was done, or a `fields` entry naming the artifact and how\n' +
'\n Add ONE of: `bluf` or `one_line` saying what was done, `verify` naming how it was checked,\n' +
' or a `fields` entry naming the artifact. Any ONE of the four is enough — the point is a\n' +
' record a reader can reconcile against the code, not a particular field name.\n' +
' it was verified. Write it now, while the work is fresh — reconstructing it later produces a\n' +
' plausible story indistinguishable from a real record.'
)
process.exit(1)
}
console.log('DONE-EVIDENCE: PASS — every newly-done card carries a bluf or fields.')
console.log('DONE-EVIDENCE: PASS — every done card carries evidence (bluf, one_line, verify or fields).')

View file

@ -5,7 +5,7 @@
* ★ WHY THIS IS NOT `git status` (a real false-pass, 2026-09-20)
* I verified the invariant for several ticks with:
* cd root-dx-life && git status --porcelain | wc -l # -> 0
* and reported that 0 to the product owner as proof. root-dx-life IS NOT A GIT REPOSITORY — it is a
* and reported that 0 to the agent as proof. root-dx-life IS NOT A GIT REPOSITORY — it is a
* plain directory clone. `git status` prints "fatal: not a git repository" to STDERR and nothing
* to stdout, so `wc -l` counts 0. THE CHECK RETURNED THE PASS VALUE *BECAUSE IT FAILED*.
* Identical in shape to the settled-tree churn bug (bfs rejected the argument, stderr discarded,

View file

@ -5,7 +5,7 @@
*
* WHY THIS GATE EXISTS. The manifest's embed.iframe snippets ARE deployment-method 1 (iframe) —
* one of only two of the five methods that are actually proven, and a shipped deliverable: a
* partner site pastes that snippet. Those snippets point at <bundle>/<profile>/index.html
* partner site pastes that snippet verbatim. Those snippets point at <bundle>/<profile>/index.html
* (and <bundle>/<profile>/<instance>/index.html). Nothing else in the build asserts the targets
* are still emitted, so a generator change that stops writing a page class turns every affected
* snippet into a 404 on someone else's website — silently, because the manifest still LOOKS right

View file

@ -2,7 +2,7 @@
/**
* check-facet-cascade.mjs — A CHILD FACET SELECTION MUST NOT OUTLIVE ITS PARENT.
*
* THE DEFECT THIS CATCHES (product owner-reported 2026-09-22): "if you have a language selected and then
* THE DEFECT THIS CATCHES (agent-reported 2026-09-22): "if you have a language selected and then
* you deselect the language family, it does not deselect the language of that language family that
* was selected... because of that I tried to go and select a new language family and it does not
* show up on the map. And I cannot see the reason why unless I look at the selected section header."
@ -42,7 +42,7 @@
* NEGATIVE-TESTED, and the negative test is part of this file: `--self-test` copies the bus, strips
* the cascade line back out, re-runs every assertion against the reverted copy and requires the
* gate to FAIL. Measured: 13 assertions fail, including "after switching to Q=… the map is EMPTY
* (Showing 0)" for all three hierarchies — i.e. it reproduces the product owner's report exactly. A gate
* (Showing 0)" for all three hierarchies — i.e. it reproduces the agent's report exactly. A gate
* nobody has broken is a gate nobody knows works.
*
* ── HERMETIC BY CONSTRUCTION (it runs inside build:unlocked) ──────────────────────────────────
@ -55,7 +55,7 @@
*
* A MISSING FIXTURE IS A FAILURE, NOT A SKIP. An earlier draft fetched live rows and skipped the
* count assertions when offline — so on a fresh clone with no network, C3 (the assertion that
* reproduces the product owner's empty map) silently did not run and the build still went green. That is
* reproduces the agent's empty map) silently did not run and the build still went green. That is
* a gate that cannot fail, which is worse than no gate. Now every assertion runs everywhere.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, cpSync } from 'node:fs'
@ -153,7 +153,7 @@ const EXPECT = {
//
// WHY A FIXTURE AND NOT "fetch, else skip". The earlier version fetched live rows and SKIPPED the
// count assertions when offline — which meant that on a fresh clone with no network, C3 (the
// assertion that actually reproduces the product owner's empty map) silently did not run and the build
// assertion that actually reproduces the agent's empty map) silently did not run and the build
// still went green. That is the "a gate that cannot fail is worse than no gate" shape: decorative
// in exactly the situation where it matters. With the fixture committed, every assertion runs
// everywhere, always, in ~1s.
@ -194,7 +194,7 @@ async function loadRows() {
// ── the facet accessors ─────────────────────────────────────────────────────────────────────
// Mirrors dashboard.config.js `facets[k].get` for the keys in the three hierarchies. Written
// out here because the config cannot be imported in Node (see the note on readFacetRail); the
// field names and the {value,label} projections are copied from it.
// field names and the {value,label} projections are copied from it verbatim.
// - most axes return the LABEL (lbl)
// - the People tiers return the CODE, because that is the value the tree filters on
// - language_family is API-value-first with a langFamilyByIso FALLBACK (the raw field is
@ -315,11 +315,11 @@ async function run(busPath) {
}
}
// ── C. the product owner's sequence, asserted on RESULT COUNTS ─────────────────────────────────────
// ── C. the agent's sequence, asserted on RESULT COUNTS ─────────────────────────────────────
if (!rows) {
// NOT a skip. The committed fixture is what makes C/D run everywhere, so its absence is a
// BROKEN GATE, not an excusable environment gap — silently skipping the assertions that
// reproduce the product owner's bug is how a gate becomes decorative. Only --offline with LIVE=1
// reproduce the agent's bug is how a gate becomes decorative. Only --offline with LIVE=1
// (an explicit opt-in to the live dataset with no cache) is allowed to degrade.
if (process.env.LIVE && OFFLINE) notes.push(' SKIP C/D — LIVE=1 --offline with no cache (explicitly opted out of the fixture)')
else bad('C/D count assertions could not run', `missing fixture ${FIXTURE} — regenerate with --refresh-fixture`)

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* check-facet-changes-map.mjs — THE GATE FOR a tracked issue.
* check-facet-changes-map.mjs — THE GATE FOR BUG-PRAYER-FILTER-NOT-APPLYING-TO-THE-MAP.
*
* ★ WHAT IT ASSERTS, and why each assertion exists rather than the obvious one.
*
@ -10,7 +10,7 @@
* (1418), summing exactly to 2107 and round-tripping on clear. The filter was never broken.
* What was broken is that every dot was painted the SAME flat orange ('circle-color' read back
* off the live layer as the bare string '#ef6c00', not an expression), so a prayer question got
* an answer with no prayer information in it. The product owner's words are a COLOUR request:
* an answer with no prayer information in it. The agent's words are a COLOUR request:
* "when I sort by has prayer it needs to SHOW ME WHICH ONES HAVE PRAYER AND WHICH ONE…
* The dots are not changing based on that filter."
* So this gate asserts BOTH halves, because either one alone is a false pass:
@ -20,14 +20,14 @@
* not a flat literal, and it reads the property the features actually carry.
*
* ★ ASSERTION 3 IS THE ONE THAT WOULD HAVE CAUGHT THE REPORTED BUG. Assertions 1 and 2 were
* already green when the product owner reported it.
* already green when the agent reported it.
*
* ★ IDENTITY, NOT JUST COUNT. The first five slugs are BYTE-IDENTICAL before and after, because
* the filter removes from the tail of an alphabetical list. A gate that eyeballed the head of the
* list, or that compared only counts, would report "looks the same" / "looks fine" for the wrong
* reason. We compare SETS of slugs and require a strict, non-empty subset relation.
*
* ★ WHY THE COLOUR CHECK IS CONDITIONAL. Orange (#ef6c00) is a product owner decision with a contrast
* ★ WHY THE COLOUR CHECK IS CONDITIONAL. Orange (#ef6c00) is a agent decision with a contrast
* analysis behind it and is correct with NO prayer filter active; the 3-tier prayer ramp is
* correct WITH one. So the gate requires a literal before and an expression after — asserting the
* SWITCH, which is the actual contract, rather than hardcoding either colour as always-right.
@ -56,7 +56,7 @@ const PATH_ = process.env.PROBE_PATH || '/'
const TAB = process.env.PROBE_TAB || 'research-dashboard'
const SELF_TEST = process.argv.includes('--self-test')
// WHICH HALF OF THE FIX TO REVERT in --self-test. Default 'colour' reverts ONLY the paint, leaving
// the feature properties intact — that is the EXACT bug the product owner reported (a correct filter whose
// the feature properties intact — that is the EXACT bug the agent reported (a correct filter whose
// dots all look the same), and it isolates assertion 3, the one assertions 1-2 cannot catch.
// 'props' strips peoplePraying instead; 'both' does the lot; 'frozen' blocks the source update
// entirely, simulating "the filter never reaches the map" and negative-testing assertion 1.
@ -86,7 +86,7 @@ function __walk(sel) {
};
visit(document); return out;
}
// THE MEASUREMENT: the map's own bubble source — the DOTS the product owner looks at — plus the paint
// THE MEASUREMENT: the map's own bubble source — the DOTS the agent looks at — plus the paint
// property that decides what each one LOOKS like.
// ★ THE MAP HANDLE CAN GO STALE. The panel destroys and re-creates its Map on a re-render (and
// the dev server's HMR does this freely), which leaves window.__rdashMap pointing at a torn-down
@ -214,6 +214,38 @@ const run = async () => {
const d = s._data; return !!(d && d.features && d.features.length > 0);
})()`, { timeout: 90000 })
} catch (e) {
// ★ DISTINGUISH "I CANNOT MEASURE THIS ENGINE" FROM "THE PRODUCT IS BROKEN".
// This gate reads the source's CURRENT GeoJSON off `_data`, a private Mapbox internal. MEASURED
// on one dashboard, one build, only the engine differing, by calling the app's own setData() to
// shrink 2107 features to 5:
// mapbox _data 2107 -> 5 _options.data 2107 -> 2107 (_data is LIVE)
// maplibre _data never an FC _options.data 2107 -> 2107 (NEITHER is live)
// So under MapLibre no private field tracks setData and the wait above can never succeed —
// and the gate then reported "the map never populated" about a dashboard that visibly renders
// all 2107 dots with a working choropleth. A control that accuses correct code is worse than no
// control; the same false-FAIL shape was just removed from check:filter-width.
// Everything below is SELF-CONTAINED page code on purpose: an earlier attempt at this called a
// node-side helper from inside a page-evaluated string, where it is simply undefined.
let diag = null
try {
diag = await page.evaluate(`(() => {
const m = window.__rdashMap; if (!m) return { noMap: true };
let s = null; try { s = m.getSource('rd-bubbles') } catch (e) {}
let engine = 'unknown';
try { engine = m.getContainer && m.getContainer().classList.contains('maplibregl-map') ? 'maplibre' : 'mapbox' } catch (e) {}
const d = s && s._data;
return { engine, hasSource: !!s, payloadReadable: !!(d && d.features), layer: !!m.getLayer('rd-bubble-layer') };
})()`)
} catch (_) { /* page gone — fall through to the original failure */ }
if (diag && diag.engine === 'maplibre' && diag.hasSource && !diag.payloadReadable) {
await browser.close()
console.log('FACET-CHANGES-MAP: SKIP — this gate reads the GeoJSON source through a private')
console.log(' Mapbox field (_data) that MapLibre does not keep live, so the dots cannot be')
console.log(' counted on this engine. The source and layer ARE present; nothing here is a')
console.log(' product finding. Run it without ?provider=maplibre, or re-express the assertion')
console.log(' on the public querySourceFeatures() — see BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE.')
process.exit(0)
}
await browser.close(); fail('the map never populated (no rd-bubbles features after 90s)')
}
await page.waitForTimeout(2500)

View file

@ -2,13 +2,13 @@
/**
* check-facet-one-field.mjs — ONE ROW-FIELD PER FACET: no two facets may be the SAME AXIS TWICE.
*
* THE DEFECT THIS CATCHES (tracked issue).
* THE DEFECT THIS CATCHES (card BUG-PEOPLE-AND-AFFINITY-BLOC-ARE-THE-SAME-AXIS-TWICE).
* The dashboard config once declared TWO facet keys over ONE row field: `affinity_bloc`, reading
* the CODE of r.rop1, and `rop1`, reading the LABEL of the very same field. That produced two
* controls for one axis — a "People" dropdown beside an "Affinity Bloc" tree tier — which could
* never recognise each other, because filteredRows byte-matches each key against its OWN accessor.
* The tree stored affinity_bloc='A012' while the dropdown stored rop1='South Asian Peoples', and
* neither ever saw the other's selection. product owner, from behaviour alone: "there are duplicate
* neither ever saw the other's selection. agent, from behaviour alone: "there are duplicate
* affinity blocks, one called People, one called Affinity Bloc... they are the same thing."
*
* ★ WHY THIS GATE ASSERTS ON OUTPUT VALUES AND NOT ON FIELD READS — THE MEASUREMENT THAT DECIDED IT.

View file

@ -7,7 +7,7 @@
* `<strong>{{ selectedOption.label }}</strong>` appeared in the label. Measured before the fix: the
* row grew 960 → 1294px and individual triggers reached 240px.
*
* ★ WHY IT IS NOT COSMETIC, in the product owner's own reasoning: the selections row with Clear All sits
* ★ WHY IT IS NOT COSMETIC, in the agent's own reasoning: the selections row with Clear All sits
* UNDERNEATH the filters. If a filter grows on selection, the row below re-flows and the whole group
* stops being the aligned block he asked for. The stable width is what makes the centred stack hold
* still — so this is a layout invariant, not a preference about widths.
@ -111,10 +111,27 @@ for (const [label, path, tab] of [
await browser.close()
let failed = 0
let unreachable = 0
// ★ AN ABSENT SERVER IS NOT A PRODUCT FINDING, A MISSING CONTROL IS.
// The rule below ("an unmeasured host is a failure") is right and is kept: if the page loads and
// the trigger or tab cannot be found, the gate has been structurally defeated and must say so.
// But it was also firing when nothing had been measured AT ALL because no server was running —
// ERR_CONNECTION_REFUSED on :4173 printed "UNMEASURED" twice and then "FAIL — 2 host(s)". That
// accuses the product of a width regression on the strength of a missing process, and it cost a
// real misreading during this flight: the gate was reported as failing when it had simply never run.
// Every other browser gate in this repo already separates the two and says so in the same words —
// check-fits-the-screen, interop-harness, check-catalog-identity and check-legend-matches-source all
// print "SKIP — ... unreachable right now. Not a product finding." This one was the outlier.
const isUnreachable = (e) => /ERR_CONNECTION_REFUSED|ERR_CONNECTION_RESET|ECONNREFUSED|net::ERR_|ERR_EMPTY_RESPONSE/i.test(String(e))
console.log('FILTER-WIDTH: a trigger must not resize when an option is selected\n')
for (const h of hosts) {
console.log(`--- ${h.label} ---`)
if (h.error) {
if (isUnreachable(h.error)) {
console.log(' UNREACHABLE: ' + h.error)
unreachable++
continue
}
// An unmeasured host is a failure. A gate that quietly drops a host reports success about
// something it never looked at.
console.log(' UNMEASURED: ' + h.error)
@ -133,6 +150,11 @@ for (const h of hosts) {
}
}
if (!failed && unreachable === hosts.length) {
console.log(`FILTER-WIDTH: SKIP — all ${hosts.length} host(s) unreachable. Not a product finding.`)
console.log(' Start a server first: `bun run serve` (or set BASE_URL).')
process.exit(0)
}
if (failed) {
console.error(`FILTER-WIDTH: FAIL — ${failed} host(s).`)
console.error(' A trigger that grows on selection re-flows the selections row beneath it, so the')

View file

@ -2,12 +2,12 @@
/**
* check-fits-the-screen.mjs — the FITNESS-FOR-USE gate. Real browser, built tree, live data.
*
* ★ WHY THIS EXISTS (tracked issue). The suite
* ★ WHY THIS EXISTS (LL-the-user-found-three-bugs-my-gates-could-not-see, 2026-09-23). The suite
* was 56/58 green, verify:gate1 13/13, verify:map-paint 11/11, verify:interop 4/4, 363 done cards
* — and the product owner opened the product and found FIVE real defects in one sitting, several caused
* — and the agent opened the product and found FIVE real defects in one sitting, several caused
* by a change a gate had certified. NOT ONE was visible to any gate, because every gate up to this
* one asserts STATE (does it mount, does it fetch, do the counts agree, does a canvas paint). The
* product owner assesses FITNESS FOR USE: is it slim, can I see it at once, does the wheel do what I
* agent assesses FITNESS FOR USE: is it slim, can I see it at once, does the wheel do what I
* expect, does it tell me what I'm looking at. A suite of the first kind can be perfectly green
* while the second kind fails completely. This gate encodes the second kind, as five clauses:
*
@ -36,14 +36,14 @@
* The direct-leaf checks (dashboard-plus-grid, research-dashboard standalone) are NOT optional and
* carry the clauses' real assertions.
*
* ★ EVERY VALUE THIS SCRIPT COMPUTES IS ASSERTED ON (tracked issue):
* ★ EVERY VALUE THIS SCRIPT COMPUTES IS ASSERTED ON (LL-a-gate-can-grade-the-wrong-variable audit):
* bandHeight, legendHeight, gridTop before/after, zoomBefore/After, scrollTopBefore/After,
* composedPath per gesture, countText before/after a filter, per-width overflow + clipped-count —
* every one of those feeds a pass/fail line in the clause table. None is logged and left unchecked.
*
* PRECONDITIONS, checked and SKIPPED (exit 0), never reported as a defect when unmet:
* - the tree must be SETTLED at START (internal/settled-tree-gate.mjs) — a mid-build tree gives
* a confident, false diagnosis (tracked issue).
* a confident, false diagnosis (LL-verify-the-tree-is-settled-before-trusting-a-browser-gate).
* - the tree must STILL be settled at END — re-checked, because a teammate's build finishing
* mid-run tears the tree out from under an in-flight measurement just as surely as one starting
* did. (A harness in this same flight was bitten checking only at the start.) If the tree moved,
@ -145,7 +145,8 @@ const record = (clause, target, ok, detail) => {
// Self-test inverts the exit contract exactly as interop-harness.mjs and check-facet-cascade.mjs
// do: a self-test run that correctly DETECTS the poisoned clause is itself the PASS. Reaching the
// end without detecting it is the failure — "I made it fail" is not evidence; the question is
// whether it failed on the SAME defect shape the real bug had (tracked issue).
// whether it failed on the SAME defect shape the real bug had (LL-negative-test-the-regression-
// not-the-feature).
let selfTestCaught = null
const fail = (clause, msg) => {
if (SELF_TEST && clause === SELF_TEST_CLAUSE && !selfTestCaught) {
@ -205,12 +206,12 @@ async function runComposite() {
// calls .enable() and nothing in the current code ever calls .disable() at all. Freezing
// .disable() therefore poisons a code path that is already dead — MEASURED: with the old
// poison AND the current "below map" test point (x=150, outside the map's own column,
// fixed alongside a tracked issue so it clears the table's ~54,500px
// fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS so it clears the table's ~54,500px
// dead zone), both sub-checks passed even with the poison live, because that point was
// never over the map's canvas regardless of scrollZoom's JS state.
//
// The risk that actually matters TODAY is a LAYOUT regression: the map's own hit-test area
// silently growing to cover ground it should not (exactly how a tracked issue first
// silently growing to cover ground it should not (exactly how BUG-MAP-EATS-THE-WHEEL first
// manifested — a Mapbox canvas full-bleeding its container). So the poison now stretches
// the map's own container to cover the "below map" test point, which is a live reproduction
// of that shape rather than toggling a dead JS flag.
@ -231,10 +232,10 @@ async function runComposite() {
// ── CLAUSE A — ONE-VIEWPORT RULE ─────────────────────────────────────────────────────────────
// .stl-sec (StackLayout's own section wrapper, library/layouts/StackLayout.vue:62) is the band
// the product owner actually sees stacked above the grid — measuring .rd-shell alone would miss a
// the agent actually sees stacked above the grid — measuring .rd-shell alone would miss a
// regression in the SECTION wrapper around it (exactly what the self-test above pokes).
//
// ★ THE CAUSAL TEST, NOT A STATIC SIZE COMPARISON (rewritten 2026-09-23, a tracked issue-
// ★ THE CAUSAL TEST, NOT A STATIC SIZE COMPARISON (rewritten 2026-09-23, BUG-DASHBOARD-BAND-
// GROWS-ENORMOUS fix). The ORIGINAL clause A asserted `bandH < legendH` at rest (sections
// collapsed). That was a correlation check calibrated to the UNFIXED bug, where the band and
// the legend's natural content height were the SAME number (band 2325px, legend scrollHeight
@ -288,7 +289,7 @@ async function runComposite() {
record('A', TARGET, fitsViewport, detail)
if (!fitsViewport) fail('a', `dashboard band is ${band.bandH}px, taller than the ${VIEWPORT_H}px viewport — it sizes to its own content instead of the screen. ` + detail)
// product owner,: (project requirement) is the
// agent, verbatim: "the dashboard rendering to the full height of the tree legend" is the
// bug — i.e. the band TRACKING the legend's content, not merely being numerically larger
// than it. Test that directly: expand every section (worst case, ~2100px of rows) and assert
// the band does not grow past the viewport with it. GROWTH_TOLERANCE_PX is generous enough to
@ -310,7 +311,7 @@ async function runComposite() {
// of whatever the cap-5 shell tab structure looks like this tick.
//
// ★ WHY THE X-COORDINATE IS THE FACETS COLUMN, NOT DASHBOARD-CENTER (fixed alongside the
// a tracked issue work, 2026-09-23). x=700 sits over [data-region="table"],
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS work, 2026-09-23). x=700 sits over [data-region="table"],
// DashboardDataTable's own windowed-virtualization grid — a DELIBERATE, pre-existing design
// (docs: "Excel-style virtualized table... 2,106 rows") whose internal scrollHeight is ~54,500px.
// A 12-tick*350px gesture (4,200px) cannot dent that before wheel-chaining to the page would
@ -364,7 +365,7 @@ async function runComposite() {
// read composedPath(). A capture-phase listener sees the event BEFORE Mapbox's own handler can
// preventDefault() it, so `defaultPrevented` after dispatch tells us whether zoom consumed it.
//
// ★ SCROLL BACK TO TOP FIRST (fixed alongside a tracked issue, 2026-09-23).
// ★ SCROLL BACK TO TOP FIRST (fixed alongside BUG-DASHBOARD-BAND-GROWS-ENORMOUS, 2026-09-23).
// Clause B just scrolled the page ~2000px+ to reach the grid and never reset. mapEl below is
// computed from [data-region="map"]'s CURRENT getBoundingClientRect(), so without a reset its
// y-coordinate comes out NEGATIVE (measured: y=-1839 after a real clause-B scroll) — an

View file

@ -10,7 +10,7 @@
* component read:
* STANDALONE research-dashboard ....... dLeft=0 dTop=4 (six of six)
* INSIDE plugin-studio dashboard tab .. dLeft=256 dTop=111 (every one)
* The product owner reported the menu appearing (project requirement) for days. It
* The agent reported the menu appearing "in a random spot further down to the right" for days. It
* was never random. 256 is the rail width, 111 the header height.
*
* ★ WHY NO EXISTING GATE SAW IT, AND WHY THAT IS THE INTERESTING PART. A probe DID exist and it DID

View file

@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* check-gospel-coverage-consistent.mjs — the gospel-resources surfaces must agree.
*
* WHY THIS EXISTS. The same defect was found by hand THREE times in one flight, and each
* time it was invisible until someone compared two surfaces:
* 1. the country sheet listed 36 groups for Indonesia while the map drew 669 pins there,
* because the sheet read a curated 2,106-record subset and the map read the pins;
* 2. the resource table silently dropped 506 real resources across 377 languages,
* because its generator skipped any code no pin speaks — in a table titled
* "every link";
* 3. resources attached to `und` ("Undetermined") told 14 people groups there was
* Scripture in a language the record explicitly does not identify.
* None of these threw. Every page rendered. Only a comparison caught them, so the
* comparison is now a gate rather than a habit.
*
* WHAT IT ASSERTS, all static — no browser, no server, runs in well under a second:
* A. no ISO 639-3 special/reserved code carries a resource (und/mul/mis/zxx/qaa-qtz);
* B. every code carrying a resource has a language NAME — an unnamed code renders as a
* bare ISO in the table and tells a reader nothing;
* C. the resource table's reach columns match an independent recount off the pins;
* D. the table covers EVERY code that has a resource — the "every link" claim;
* E. every resource_url in the table exists in the pin index, so the table cannot
* invent a link the map does not hold;
* F. each pin's tier agrees with the URLs its code actually carries;
* I. a row's TYPE agrees with the host it points at — Film only on jesusfilm.org or
* arc.gt, Bible Stories only on door43 and only under that name, Scripture never on
* a film host. Clause H catches the one mislabel that actually shipped; this catches
* the general shape, because the 70-row defect was invisible for seven ticks
* precisely because NOTHING compared a row's label to where it points. Audited at
* the time of writing: 3,550 rows, 0 violations — so this is a guard, not a fix.
* H. nothing named "Open Bible Stories" is TYPED as Scripture. OBS is 50 illustrated
* narratives drawn from the canon, not a translation, and this repo's standing rule
* is that the two are never blurred. It broke silently: owner disambiguation changed
* the label to "Open Bible Stories (OBS-TLF)" and the type test was an exact ===
* match, so 70 story sets were typed Scripture and stayed that way for seven ticks.
* An exact match against a label that can grow a suffix is the whole defect.
* G. no URL is plain http. Every host we ship serves https directly and redirects
* http there anyway, so a plain link only buys a redirect hop and a downgraded
* scheme in a field worker's browser. 1,237 of them shipped that way until the
* normalisation moved to the point where a URL ENTERS the index — doing it
* per-source had left the 120 curated links behind, since fill-never-overwrite
* preserves those and they never passed through a fetcher.
*
* ★ SELF-TEST: run with --poison to inject each defect in memory and confirm the clause
* catches it. A check that has never been shown to fail proves nothing, and five of the
* six clauses here are asserting a NEGATIVE, which is exactly the shape that passes
* vacuously when the data it reads is empty or mis-keyed.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const POISON = process.argv.includes('--poison')
let poisonStale = false
const read = (p) => JSON.parse(readFileSync(resolve(REPO, p), 'utf8'))
let pins, table, verif
try {
pins = read('data/tables/people-group-pins.json')
table = read('data/tables/gospel-resource-links.json')
try { verif = read('data/tables/gospel-link-verification.json') } catch { verif = null }
} catch (e) {
console.error(`COVERAGE-CONSISTENT: SKIP — inputs missing (${e.message}).`)
console.error(' Run: node plugin-designs/gospel-resources/scripts/build-people-group-pins.mjs')
process.exit(0)
}
const codes = { ...pins.codes }
let rows = pins.pins
let trows = table.rows
// A gate that reads an empty corpus passes every negative clause while measuring nothing.
if (!rows?.length || !trows?.length || !Object.keys(codes).length) {
console.error('COVERAGE-CONSISTENT: FAIL — an input is empty; every clause below would pass vacuously.')
process.exit(1)
}
if (POISON) {
// one injected defect per clause, in memory only
codes.und = { l: 'Undetermined', d: [{ s: 'Open Bible Stories', u: 'https://example.invalid/und' }] } // A
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B + G
trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C
{ ...trows[0], resource: 'Open Bible Stories (X)', type: 'Scripture' }, // H
{ ...trows[0], type: 'Film', resource_url: 'https://ebible.org/xyz/' }, // I
...trows.slice(1, -1),
{ ...trows[trows.length - 1], resource_url: 'https://example.invalid/invented' }] // E
rows = [[...rows[0].slice(0, 6), 2], ...rows.slice(1)] // F (forced tier 2)
codes.zzj = { l: 'Both at once', z: 'https://zume.training/en', za: { u: 'https://zume.training/pus', l: 'Pashto', n: 'x' } } // J
codes.zzk = { l: 'Ships a known-dead link', f: 'https://jesusfilm.org/watch/jesus/4101' } // K
poisonStale = true // L
}
const SPECIAL = (c) => c === 'und' || c === 'mul' || c === 'mis' || c === 'zxx' || (c >= 'qaa' && c <= 'qtz')
const hasRes = (v) => !!(v && (v.s || v.f || v.au || v.bv || (v.d && v.d.length)))
const fails = []
const add = (clause, msg) => fails.push(` [${clause}] ${msg}`)
// A — no resource on a code that is not a language
for (const [c, v] of Object.entries(codes)) {
if (SPECIAL(c) && hasRes(v)) add('A', `${c} is an ISO reserved code and carries a resource`)
}
// B — a code with a resource must be named
for (const [c, v] of Object.entries(codes)) {
if (hasRes(v) && !String(v.l || '').trim()) add('B', `${c} carries a resource but has no language name`)
}
// C — reach columns recounted off the pins
const groups = {}, speakers = {}
for (const r of rows) { groups[r[4]] = (groups[r[4]] || 0) + 1; speakers[r[4]] = (speakers[r[4]] || 0) + (r[5] || 0) }
let cBad = 0
for (const t of trows) {
if ((groups[t.iso639_3] || 0) !== t.people_groups) cBad++
else if ((speakers[t.iso639_3] || 0) !== t.speakers) cBad++
}
if (cBad) add('C', `${cBad} table row(s) disagree with a pin recount of people_groups/speakers`)
// D — every code with a resource is represented in the table
const inTable = new Set(trows.map((t) => t.iso639_3))
const missing = Object.keys(codes).filter((c) => hasRes(codes[c]) && !inTable.has(c))
if (missing.length) add('D', `${missing.length} code(s) carry a resource but appear in no table row (e.g. ${missing.slice(0, 3).join(', ')})`)
// E — the table cannot invent a URL
const known = new Set()
for (const v of Object.values(codes)) {
if (v.s) known.add(v.s); if (v.f) known.add(v.f); if (v.z) known.add(v.z); if (v.za) known.add(v.za.u); if (v.au) known.add(v.au.u); if (v.bv) known.add(v.bv.u); if (v.gp) known.add(v.gp.u)
for (const d of v.d || []) known.add(d.u)
}
const invented = trows.filter((t) => t.resource_url && !known.has(t.resource_url))
if (invented.length) add('E', `${invented.length} table URL(s) exist in no pin record (e.g. ${invented[0].resource_url})`)
// I — type must agree with the host
const FILM_HOST = /(^|\.)(jesusfilm\.org|arc\.gt)$/i
const hostOf = (u) => (/^https?:\/\/([^/]+)/i.exec(u || '') || [, ''])[1].toLowerCase()
const typeBad = []
for (const t of trows) {
const h = hostOf(t.resource_url)
if (!h) continue
const film = FILM_HOST.test(h)
if (t.type === 'Film' && !film) typeBad.push(`Film on ${h}`)
else if (t.type === 'Scripture' && film) typeBad.push(`Scripture on film host ${h}`)
else if (t.type === 'Bible Stories' && !/(^|\.)git\.door43\.org$/i.test(h)) typeBad.push(`Bible Stories on ${h}`)
else if (t.type === 'Bible Stories' && !/^Open Bible Stories/.test(t.resource || '')) typeBad.push(`Bible Stories named "${t.resource}"`)
// Training is a COURSE, and the only publisher of it here is Zume. Without this the
// type fell through every branch above and was checked by nothing at all.
else if (t.type === 'Discipleship training' && !/(^|\.)zume\.training$/i.test(h)) typeBad.push(`Discipleship training on ${h}`)
else if (/(^|\.)zume\.training$/i.test(h) && t.type !== 'Discipleship training') typeBad.push(`${t.type} on zume.training`)
// Audio Scripture is published here by exactly one host. Without this the type fell
// through every branch above and was checked by nothing, the same hole training had.
else if (t.type === 'Audio Scripture' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Audio Scripture on ${h}`)
// bible.is serves BOTH: a fileset may carry audio, text or both, so the honest set of
// types on this host is two, not one. It stays a CLOSED set - anything else here is a
// mislabel - and 'Audio Scripture' on any other host is still wrong.
else if (/(^|\.)bible\.is$/i.test(h) && t.type !== 'Audio Scripture' && t.type !== 'Scripture' && t.type !== 'Bible video') typeBad.push(`${t.type} on bible.is`)
// knowgod.com serves exactly one type here: a gospel presentation. A closed set again.
else if (t.type === 'Gospel presentation' && !/(^|\.)knowgod\.com$/i.test(h)) typeBad.push(`Gospel presentation on ${h}`)
else if (/(^|\.)knowgod\.com$/i.test(h) && t.type !== 'Gospel presentation') typeBad.push(`${t.type} on knowgod.com`)
else if (t.type === 'Bible video' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Bible video on ${h}`)
else if (t.type === 'Audio Scripture' && !/(^|\.)bible\.is$/i.test(h)) typeBad.push(`Audio Scripture on ${h}`)
}
if (typeBad.length) add('I', `${typeBad.length} row(s) whose type disagrees with their host (e.g. ${typeBad[0]})`)
// H — a story set must never be typed as Scripture
const misTyped = trows.filter((t) => /open bible stories/i.test(t.resource || '') && /^scripture$/i.test(t.type || ''))
if (misTyped.length) add('H', `${misTyped.length} row(s) named "Open Bible Stories" are typed as Scripture (e.g. ${misTyped[0].language})`)
// G — no plain-http URL
const httpUrls = []
for (const [c, v] of Object.entries(codes)) {
for (const u of [v.s, v.f, v.z, v.za && v.za.u, v.au && v.au.u, v.bv && v.bv.u, v.gp && v.gp.u, ...(v.d || []).map((d) => d.u)]) {
if (u && /^http:\/\//i.test(u)) httpUrls.push(`${c} ${u}`)
}
}
if (httpUrls.length) add('G', `${httpUrls.length} plain-http URL(s) (e.g. ${httpUrls[0]})`)
// F — tier agrees with the URLs the code holds
let fBad = 0
for (const r of rows) {
const v = codes[r[4]] || {}
const want = v.s && v.f ? 2 : (v.s || v.f || (v.d && v.d.length) || v.au || v.bv) ? 1 : 0
if (want !== r[6]) fBad++
}
if (fBad) add('F', `${fBad} pin(s) carry a tier that disagrees with their code's URLs`)
// J — an approximate match must never sit alongside a confirmed one on the same code.
// The map styles them differently and says different things, so a code holding both would
// render the qualified claim and the confirmed one as if they were the same resource, and
// the qualification is the entire point of keeping them apart.
const both = Object.entries(codes).filter(([, v]) => v.z && v.za).map(([c]) => c)
if (both.length) add('J', `${both.length} code(s) carry BOTH a confirmed and an approximate training link (e.g. ${both[0]})`)
// An approximate entry must still be a real, complete record rather than a bare URL.
const thin = Object.entries(codes).filter(([, v]) => v.za && !(v.za.u && v.za.l && v.za.n)).map(([c]) => c)
if (thin.length) add('J', `${thin.length} approximate entr(ies) missing a url, language or note (e.g. ${thin[0]})`)
// K — verification must actually cover what is shipped, and nothing shipped may be known
// dead. The previous table matched TWO of 3,605 links because its keys were written http://
// and against a URL shape the index had since replaced; the map showed "unchecked" on links
// that had in fact been verified and nobody could tell that from never-looked-at. A coverage
// floor is the only thing that makes that failure loud.
if (verif && verif.links) {
const V = verif.links
const shipped = new Set()
for (const v of Object.values(codes)) {
if (v.s) shipped.add(v.s)
if (v.f) shipped.add(v.f)
if (v.z) shipped.add(v.z)
if (v.za && v.za.u) shipped.add(v.za.u)
if (v.au && v.au.u) shipped.add(v.au.u)
if (v.bv && v.bv.u) shipped.add(v.bv.u)
if (v.gp && v.gp.u) shipped.add(v.gp.u)
for (const d of v.d || []) if (d.u) shipped.add(d.u)
}
// EVERY shipped URL must be in the record, not 95% of them. The floor was set at 95%
// and that is exactly how two known-dead links got back in: dropping them removed them
// from the corpus, the next verification run never saw them, and 2 missing out of 3,768
// is 99.9% — comfortably above a 95% floor and completely wrong. "Verify before you
// ship it" does not have a tolerance.
const unknownToRecord = [...shipped].filter((u) => !V[u])
if (unknownToRecord.length) {
add('K', `${unknownToRecord.length} shipped URL(s) appear in no verification record (e.g. ${unknownToRecord[0]}) — run scripts/verify-gospel-links.mjs`)
}
const dead = [...shipped].filter((u) => V[u] && V[u].state === 'broken')
if (dead.length) add('K', `${dead.length} shipped URL(s) are recorded BROKEN and must not ship (e.g. ${dead[0]})`)
}
// L — the verification record must be FRESH, and every shipped URL must carry a stamp.
// "Link checked" is a claim about the present tense. A table that silently stops being
// re-run keeps making that claim on evidence that may be a year old, and every clause
// above would still pass: they check that the record is CONSISTENT, never that it is
// current. A dead link is exactly the failure a field worker hits, so the check that the
// record is recent is not bookkeeping.
// Re-running is cheap and resumable now (VERIFY_MAX_AGE_H skips what is already fresh),
// so this failing is an instruction, not an obstacle.
if (verif && verif.links) {
const V = verif.links
const MAX_AGE_DAYS = Number(process.env.VERIFY_MAX_AGE_DAYS || 45)
const shipped2 = new Set()
for (const v of Object.values(codes)) {
if (v.s) shipped2.add(v.s)
if (v.f) shipped2.add(v.f)
if (v.z) shipped2.add(v.z)
if (v.za && v.za.u) shipped2.add(v.za.u)
if (v.au && v.au.u) shipped2.add(v.au.u)
if (v.bv && v.bv.u) shipped2.add(v.bv.u)
if (v.gp && v.gp.u) shipped2.add(v.gp.u)
for (const d of v.d || []) if (d.u) shipped2.add(d.u)
}
const unstamped = [...shipped2].filter((u) => V[u] && !V[u].at)
if (unstamped.length) {
add('L', `${unstamped.length} shipped URL(s) carry no check date (e.g. ${unstamped[0]}) — rerun scripts/verify-gospel-links.mjs`)
}
let oldest = Infinity
let oldestUrl = ''
for (const u of shipped2) {
const at = V[u] && V[u].at ? Date.parse(V[u].at) : NaN
if (!Number.isFinite(at)) continue
if (at < oldest) { oldest = at; oldestUrl = u }
}
const nowMs = poisonStale ? Date.now() + (MAX_AGE_DAYS + 10) * 86400000 : Date.now()
if (Number.isFinite(oldest)) {
const days = (nowMs - oldest) / 86400000
if (days > MAX_AGE_DAYS) {
add('L', `the oldest shipped link was last checked ${days.toFixed(0)} days ago (${oldestUrl}), past the ${MAX_AGE_DAYS}-day limit — rerun VERIFY_MAX_AGE_H=24 node scripts/verify-gospel-links.mjs`)
}
}
}
const clauses = new Set(fails.map((f) => f.slice(3, 4)))
if (POISON) {
// D is included deliberately: it is the clause that would have caught the 506
// resources dropped from a table titled "every link", and the most valuable clause is
// the worst one to leave unproven. The injected codes above (und, zzq) are absent from
// the table, so they poison D as well as A and B.
const want = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
const caught = want.filter((c) => clauses.has(c))
console.log(`COVERAGE-CONSISTENT --poison: ${caught.length}/${want.length} clause(s) caught their injected defect (${caught.join(',')})`)
if (caught.length !== want.length) {
console.error(` MISSED: ${want.filter((c) => !clauses.has(c)).join(',')} — those clauses cannot fail and prove nothing.`)
process.exit(1)
}
console.log('COVERAGE-CONSISTENT: SELF-TEST PASS — every poisoned clause fails as designed.')
process.exit(0)
}
if (fails.length) {
console.error(`COVERAGE-CONSISTENT: FAIL — ${fails.length} problem(s):`)
for (const f of fails.slice(0, 20)) console.error(f)
process.exit(1)
}
console.log(`COVERAGE-CONSISTENT: PASS — ${rows.length} pins, ${Object.keys(codes).length} codes, ${trows.length} table rows agree.`)
// Name every clause. A summary that lists four of nine reads as a narrower check than it
// is, and the next reader calibrates their trust on this line rather than the source.
console.log(' A no resource on an ISO reserved code B every resourced code is named')
console.log(' C reach recounted off the pins D every resourced code is in the table')
console.log(' E no table URL the pins do not hold F pin tiers match their code\'s URLs')
console.log(' G no plain-http URL H no story set typed as Scripture')
console.log(' I row type agrees with its host J approximate never reads as confirmed')
console.log(' K every shipped link is verified, and none known-broken ships')
console.log(' L the verification record is current, not merely consistent')

View file

@ -2,15 +2,15 @@
/**
* check-heartbeat-threshold.mjs — THE HEARTBEAT THRESHOLD MUST COME FROM CONFIG, NOT A LITERAL.
*
* WHAT A FOCUS ZONE IS. The top bin of the research-dashboard choropleth: the countries
* holding the most people groups. product owner: "the focus zones are the most people groups in
* them... that's going to be 150 plus people groups in those focus zones."
* WHAT A HEARTBEAT ZONE IS. The top bin of the research-dashboard choropleth: the countries
* holding the most people groups. agent: "the heartbeat zones are the most people groups in
* them... that's going to be 150 plus people groups in those heartbeat zones."
*
* ★ THE DEFECT THIS CATCHES, AND WHY NOTHING ELSE COULD. The ramp's edges were a 5-stop array
* literal — `upTo: 5, 20, 60, 150, Infinity` — sitting in DashboardMapPanel.vue. Those numbers are
* not a property of choropleths; they are a property of THIS DATASET (~2000 people groups). Point
* the dashboard at a 200-group dataset and every country falls in the bottom bin: the map renders
* uniformly pale and says nothing. product owner: "that filter of 150 ... should be a variable. It
* uniformly pale and says nothing. agent: "that filter of 150 ... should be a variable. It
* shouldn't be a static ... I need to be able to reuse this dashboard for other maps, other data
* sets." A dataset assumption wearing the costume of a constant.
*
@ -46,7 +46,7 @@
* to exactly that number and must RE-BIN the fixture's countries. This is the assertion a
* re-hard-coded literal fails.
* C. THE LEGEND FOLLOWS THE EDGES — the top label must CONTAIN "<threshold>+" and name the
* focus zone. A moved edge with a stale '150+' legend is confidently wrong, which is
* heartbeat zone. A moved edge with a stale '150+' legend is confidently wrong, which is
* worse than no legend. Matched by property, not equality — see the note on HISTORICAL.
* D. NO DEAD BINS — every bin must be reachable (`colorFor` tests `count < upTo`, so an edge of
* 1 creates a band no count can enter: the ramp paints 4 of its 5 colours while the legend
@ -71,7 +71,7 @@
* ★ THE FIRST VERSION OF THIS GATE SHIPPED WITH [literal] ONLY, and its header said it had been
* "negative-tested" — true of that case, misleading overall. Testing that THE FEATURE WORKS (a
* configured threshold moves the edge) is NOT testing that THE REGRESSION IS CAUGHT (the default
* silently changing), and only the second protects the product owner. One negative test per regression
* silently changing), and only the second protects the agent. One negative test per regression
* claimed. A gate that cannot fail is worse than no gate — this repo has shipped one.
*
* NOT COVERED, deliberately: this gate reasons about BIN ASSIGNMENT and LABELS only. It says
@ -93,7 +93,7 @@ const SELF_TEST = process.argv.includes('--self-test')
//
// ★ THE TOP LABEL IS MATCHED BY PROPERTY, NOT BY EQUALITY — a correction this gate earned on its
// FIRST RUN. It originally demanded the top label equal exactly "150+", and immediately failed
// CORRECT code: the label reads "150+ · focus zone", because naming the band in the product owner's
// CORRECT code: the label reads "150+ · heartbeat zone", because naming the band in the agent's
// own words is a requirement of this work, not a defect. An over-strict gate that fires on correct
// code gets switched off, and then the real regression walks through. So the assertion is the
// property that actually matters: the top label must CARRY THE CURRENT THRESHOLD (so a moved edge
@ -201,7 +201,7 @@ function run(srcOverride) {
fail(`${tag}. RAMP MOVED at bin ${i}: expected upTo=${exp.upTo}, got ${got ? got.upTo : '(missing)'}.\n ${why}`)
} else if (exp.l === null) {
if (!topLabelOk(got.l, 150)) {
fail(`${tag}. TOP LABEL is "${got.l}" — it must contain "150+" and name the focus zone.\n ${why}`)
fail(`${tag}. TOP LABEL is "${got.l}" — it must contain "150+" and name the heartbeat zone.\n ${why}`)
}
} else if (got.l !== exp.l) {
fail(`${tag}. LEGEND LABEL MOVED at bin ${i}: expected "${exp.l}", got "${got.l}".\n ${why}`)
@ -259,7 +259,7 @@ function run(srcOverride) {
}
if (!topLabelOk(top.l, t)) {
fail(`C. LEGEND DID NOT FOLLOW THE EDGE at threshold ${t}: top label reads "${top.l}".\n` +
` It must contain "${t}+" and name the focus zone. A moved edge with a stale number on the\n` +
` It must contain "${t}+" and name the heartbeat zone. A moved edge with a stale number on the\n` +
' legend is confidently wrong, which is worse than no legend.')
}
bins.forEach((b, i) => {
@ -290,7 +290,7 @@ function run(srcOverride) {
// ★ TWO CASES, BECAUSE THERE ARE TWO DISTINCT REGRESSIONS AND THEY ARE NOT THE SAME ASSERTION.
// This gate originally shipped with case 1 only, and its header claimed it was "negative-tested".
// That was true of case 1 and MISLEADING overall: case 2 — the one that actually protects the
// product owner's "today's map must not change" requirement — was unasserted, and a one-line edit to
// agent's "today's map must not change" requirement — was unasserted, and a one-line edit to
// dashboard.config.js changed the shipped ramp while the gate printed PASS. Verified by
// reproducing it, not by reasoning about it.
//

View file

@ -0,0 +1,120 @@
// check-i18n-parity — a key that exists in English must exist in every locale.
//
// ★ THE FAILURE IS SILENT BY DESIGN. vue-i18n falls back to English for a missing
// key, which is the right runtime behaviour and the reason this drifts unnoticed:
// the app looks fine, nothing errors, and a reader who chose Spanish simply gets
// English words with no indication anything is missing.
//
// Measured when this was written: buttons.json had 21 keys in English and 19 in
// every other locale. The two orphans were `share` and `shareSteps` — the Share
// button's own title and its five-step explainer, rendered on EVERY map. Ten
// locales had been shipping an English Share button since the key was added.
//
// It reports per-locale so a partial translation is visible as a number rather
// than as a vague sense that "some things aren't translated".
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
// ⚠ REPO-RELATIVE, never an absolute path. This gate exists in MORE THAN ONE repo
// (doxa-maps-plugin-studio and wt-packet are separate repos, not worktrees), and a
// hardcoded /home/... made the studio copy silently grade wt-packet's locales —
// so a ceiling lowered in one repo left the other's stale and neither was wrong on
// its face. Each copy now checks ITS OWN tree. (check:no-abs-paths, 2026-09-24.)
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const ROOT = join(REPO, 'library/i18n/locales')
if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) }
// Flatten so nested objects and arrays are compared key-by-key, not shallowly.
const flat = (obj, prefix = '', out = {}) => {
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
else out[key] = v
}
return out
}
const read = (loc, area) => {
const p = join(ROOT, loc, area)
if (!existsSync(p)) return null
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return undefined }
}
const locales = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d)))
if (!locales.includes('en')) { console.error('I18N-PARITY: FAIL — no English catalog to compare against'); process.exit(1) }
const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
// ── KNOWN DEBT, RATCHETED — AND THE MAP IS NOW EMPTY ────────────────────────
// ★ ALL ELEVEN LOCALES ARE COMPLETE. 2026-09-24: the last 162 keys closed
// (regions.json for it/ro/zh, 54 each), finishing the 429-key debt this gate
// found when it was written.
//
// THE MAP IS DELIBERATELY EMPTY RATHER THAN FULL OF ZEROS. A locale with no entry
// here MUST be complete — the gate fails it outright for a single missing key —
// so an empty DEBT is the strongest state this ratchet can be in, not an unset
// one. Writing `{ it: 0, ... }` would mean the same today and quietly permit a
// regression back up the moment someone "adjusted" a number.
//
// HOW IT WORKED, for whoever needs to add debt back: these were a CEILING, not a
// target — the gate failed when a locale got WORSE and printed a nudge when one
// improved, so the number could be lowered. That let real translation work sit in
// the backlog without blocking every build, which is what a gate everyone
// disables protects against. If a new English key lands ahead of its
// translations, add the locale here with its count and lower it as they arrive.
// Never raise a number; never add a locale that is already complete.
const DEBT = {
}
const fails = []
const improved = []
const rows = []
for (const loc of locales.filter((l) => l !== 'en').sort()) {
let missing = 0
let total = 0
const examples = []
for (const area of areas) {
const en = read('en', area)
const other = read(loc, area)
total += Object.keys(en).length
if (other === null) { missing += Object.keys(en).length; examples.push(`${area} (whole file)`); continue }
if (other === undefined) { fails.push(`${loc}/${area} is not valid JSON`); continue }
for (const k of Object.keys(en)) {
if (!(k in other)) { missing += 1; if (examples.length < 3) examples.push(`${area}:${k}`) }
}
}
rows.push({ loc, missing, total, examples })
const ceiling = DEBT[loc]
if (ceiling === undefined) {
if (missing) fails.push(`${loc} is missing ${missing}/${total} key(s) and has no DEBT entry — `
+ `e.g. ${examples.join(', ')}`)
} else if (missing > ceiling) {
fails.push(`${loc} regressed: ${missing} missing key(s), was ${ceiling} — `
+ `e.g. ${examples.join(', ')}`)
} else if (missing < ceiling) {
improved.push(`${loc}: ${missing} missing, ceiling still says ${ceiling} — lower it`)
}
}
console.log(` locales : ${locales.length} (${locales.sort().join(' ')})`)
console.log(` areas : ${areas.length} catalog file(s), ${Object.keys(read('en', areas[0]) || {}).length}+ keys each`)
for (const r of rows) {
const c = DEBT[r.loc]
const tag = r.missing === 0 ? 'complete'
: (c !== undefined && r.missing <= c ? `${r.missing} missing (known debt, ceiling ${c})`
: `${r.missing} missing`)
console.log(` ${r.loc.padEnd(4)} ${tag}`)
}
if (fails.length) {
console.error(`\nI18N-PARITY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
if (improved.length) {
console.log('\n ↓ improved since the ceiling was set — lower DEBT in this file:')
for (const i of improved) console.log(' ' + i)
}
const debt = rows.reduce((a, r) => a + r.missing, 0)
console.log(`\nI18N-PARITY: PASS — no locale regressed. ${debt} key(s) of known translation debt remain.`)

View file

@ -0,0 +1,148 @@
#!/usr/bin/env node
// check-legend-info-localized — the legend's INFO POPOVERS must translate, in the
// real served page, not in a reimplementation of the lookup.
//
// ★ WHY THIS EXISTS BESIDE check:tabs-localized. That gate proves the dynamic-key
// mechanism reaches tabs.json — genuinely valuable, and it passes. But it tests SEVEN
// HARDCODED research-map tabs and it REIMPLEMENTS tabLabel() inside page.evaluate with
// catalogs fed in by hand. So it proves "tab localization works, for these seven",
// which is not the same claim as "this profile's tabs are localized". The UPG-100
// legend's three tabs were absent from tabs.json entirely and that gate stayed green.
//
// ⚠ AND IT NEVER LOOKED AT `info` AT ALL. SemanticTreeLegend rendered `tab.info` RAW in
// all three places it appears (the button's title=, and the popover body) while the tab
// LABEL beside it went through the catalog. Measured 2026-09-24 on the served leaf:
// ?lang=es changed 2 of 76 rendered strings ("POPULATION" -> "POBLACIÓN") and left all
// three tabs AND all three info popovers in English. The mechanism worked; the strings
// were never catalogued. The agent named this affordance specifically — "the tab itself
// gives you a little information button that will explain what a UPG-100 list is" —
// which is exactly the sentence a reader who does not read English cannot use.
//
// ⚠ IT RENDERS THE REAL PAGE. A catalog-only check cannot tell a shipped translation
// from a dead one, and a reimplemented lookup cannot tell you the component actually
// calls it. This opens the served leaf, switches locale the way a reader does (?lang=),
// CLICKS each info button and reads the popover that paints.
//
// ⚠ WAIT FOR THE FLUSH. showInfo() sets a ref; reading the DOM in the same tick as the
// click finds no popover and reads as "the affordance is broken". Measured: the first
// version of this probe reported "(no popover)" in every locale against a working build.
//
// Usage: node internal/check-legend-info-localized.mjs <leafUrl> [locales]
import { chromium } from 'playwright'
import { readFileSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
const LOCALES = join(REPO, 'library/i18n/locales')
const URL_ = process.argv[2] || process.env.LEGEND_URL
if (!URL_) {
console.log('LEGEND-INFO-LOCALIZED: SKIP — no leaf url given.')
console.log(' Pass one, or set LEGEND_URL. Serve the TREE ROOT (public/doxa-maps) and point')
console.log(' at a profile whose legend carries per-tab info buttons, e.g.')
console.log(' my-upg-100-list/upg-100-list/index.html')
process.exit(0)
}
const UNDER_TEST = (process.argv[3] || 'es,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
const legendCat = (loc) => {
const p = join(LOCALES, loc, 'legend.json')
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null
}
// Read each locale's info popovers from the REAL page, clicking every info button.
async function readLegend(page, url, lang) {
const errs = []
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
await page.goto(`${url}?lang=${lang}`, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(9000)
const walkFn = () => {
const walk = (root, out = []) => {
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
return out
}
return walk(document)
}
const count = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info')).length`)
const pops = []
for (let i = 0; i < count; i++) {
await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info'))[${i}]?.click()`)
// ⚠ showInfo() sets a ref — read AFTER Vue flushes, or every popover reads empty.
await page.waitForTimeout(400)
pops.push(await page.evaluate(`(() => { const el = (${walkFn.toString()})().find(e => (e.className||'').toString().includes('stl-info-pop')); return el ? (el.innerText||'').trim() : '' })()`))
}
const tabs = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-chip')).map(c => (c.innerText||'').trim().split('\\n')[0]).filter(Boolean)`)
return { tabs, pops, errs }
}
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const seen = {}
try {
for (const loc of ['en', ...UNDER_TEST]) {
const page = await browser.newPage({ viewport: { width: 1500, height: 1000 } })
seen[loc] = await readLegend(page, URL_, loc)
await page.close()
}
} finally {
await browser.close()
}
const en = seen.en
const fails = []
// PRECONDITION: this page must actually HAVE the affordance, or every assertion below
// is vacuous. A leaf whose bundle failed to execute renders zero tabs with zero page
// errors — the stale-SRI signature — and would otherwise pass as "nothing untranslated".
if (!en.pops.length) {
console.error('\nLEGEND-INFO-LOCALIZED: FAIL — no info buttons rendered at all in English.')
console.error(' Either this profile has no per-tab info affordance (wrong url), or the bundle')
console.error(' did not execute. A stale SRI hash refuses the script SILENTLY, with no page')
console.error(` error and zero tabs — run \`bun run tree\` after \`BUNDLE=<n> bun x vite build\`.`)
console.error(` tabs rendered: ${JSON.stringify(en.tabs)} page errors: ${en.errs.length}`)
process.exit(1)
}
// THE LOAD-BEARING ASSERTION. A chosen locale must CHANGE the info text — that is the
// difference between a translation that ships and one that merely exists on disk.
const separable = {}
for (const loc of UNDER_TEST) {
const s = seen[loc]
if (!s.pops.length) { fails.push(`${loc}: no info popovers rendered (English had ${en.pops.length})`); continue }
if (s.pops.length !== en.pops.length) {
fails.push(`${loc}: ${s.pops.length} info popover(s), English had ${en.pops.length}`)
}
// NEVER BLANK. The whole point of a per-key fallback is that an untranslated key
// shows English rather than nothing.
const blank = s.pops.filter((t) => !t.trim()).length
if (blank) fails.push(`${loc}: ${blank} info popover(s) rendered BLANK — the fallback should show English`)
// Count how many DIFFER from English. Zero means the catalog is not reached: either
// the entries are missing, or the component renders the raw prop as it used to.
const diff = s.pops.filter((t, i) => t && en.pops[i] && t !== en.pops[i]).length
separable[loc] = diff
const cat = legendCat(loc)
const catKeys = cat && cat.tabInfo ? Object.keys(cat.tabInfo).length : 0
if (catKeys > 0 && diff === 0) {
fails.push(`${loc}: legend.json declares ${catKeys} tabInfo key(s) but NOT ONE info popover `
+ 'differs from English — the catalog is present and unreached (the raw-prop bug)')
}
if (s.errs.length) fails.push(`${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
}
console.log('LEGEND-INFO-LOCALIZED')
console.log(` leaf : ${URL_}`)
console.log(` info popups: ${en.pops.length} (English)`)
console.log(` tabs : ${en.tabs.join(' · ')}`)
for (const loc of UNDER_TEST) {
const s = seen[loc]
console.log(` ${loc.padEnd(10)}: ${separable[loc] ?? 0}/${s.pops.length} info text(s) differ from English, `
+ `${s.pops.filter((t) => !t.trim()).length} blank tabs: ${s.tabs.join(' · ')}`)
}
if (fails.length) {
console.error(`\nLEGEND-INFO-LOCALIZED: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nLEGEND-INFO-LOCALIZED: PASS — choosing a locale changes the rendered info text, so '
+ 'the legend\'s explanations reach the reader in their own language, never blank.')

View file

@ -71,7 +71,7 @@ const EXPECT = {
// and 5 have FOUR each. The "or Resources" clause is not literally true in the data, so that phrase
// must never be repeated to a mission team as fact.
//
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is a product owner decision
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is a agent decision
// (DRIVER-DECISIONS-gospel-resources.md, Decision 1), and a gate must not quietly canonise one answer
// to a question that is still open. Recorded so the next reader inherits the measurement, not the
// task of re-deriving it.

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* check-library-index.mjs — the derived library menu must not fall behind library/.
* check-library-index.mjs — the derived library menu must not DRIFT from library/.
*
* THE BUG THIS EXISTS TO PREVENT (real, 2026-09-21)
* internal/generate-library-index.mjs opens with: "The index is DERIVED, never hand-edited — so it
@ -8,29 +8,70 @@
* no package.json entry, and documentation/reference-library-index.md DID NOT EXIST AT ALL. A documented
* invariant enforced only by a human remembering is a wish, not an invariant.
*
* ★ IT ASKS THE REAL QUESTION NOW: "HAS THE MENU DRIFTED?", NOT "IS THIS FILE OLDER?"
* This compared MTIMES until 2026-09-24, and an mtime is a proxy that answers a different question
* than the one the gate's own name asks. MEASURED: `touch library/components/legends/
* SemanticTreeLegend.vue` — zero content change — turned it RED. So did a `git checkout`, a rebase,
* and any editor that rewrites a file on save. It cried wolf TWICE in a single build session while
* the regenerated doc was byte-identical both times, and the only available response was to run the
* generator and commit nothing.
*
* That is the SECOND time this exact class was fixed here. The first (2026-09-23, note below) was a
* SCOPE error — the gate walked 2.3x more of library/ than the generator reads, so adding a comment
* to a build script turned it red and named that script as the reason the component MENU was stale.
* Narrowing the scope fixed that instance; the MECHANISM was still mtime, so the class survived
* inside the narrowed scope. The lesson recorded then is the one that applies now, verbatim:
* "A gate that fails for a change it can prove is irrelevant teaches the reader to regenerate on
* reflex, which is how a staleness check stops being read."
*
* HOW: regenerate into a THROWAWAY TREE and diff the bytes. generate-library-index.mjs resolves its
* ROOT from its own location (`join(fileURLToPath(new URL('.', import.meta.url)), '..')`) and
* hardcodes its output path, so it cannot be told to write elsewhere — but copying the SCRIPT into a
* temp tree whose `library/` is a SYMLINK to the real one relocates its ROOT without touching it.
* The generator runs completely unmodified, reads the real sources, and writes somewhere disposable.
* Nothing in the working tree is stashed or mutated, so an interrupted run leaves no mess.
* VERIFIED byte-identical to a real `bun run library-index` before this gate was written.
*
* PRECONDITION, tested before relying on it: the generator is DETERMINISTIC — two runs a second
* apart produce identical bytes (no timestamps, no directory-order dependence). A content diff would
* be useless otherwise, and would have been a worse gate than the mtime one it replaces.
*
* WHY RAW SOURCE IS THE RIGHT ANCESTOR HERE (and was NOT for the screenshots)
* check-shot-freshness compares a PNG against the BUILT BUNDLE, because a screenshot renders a
* built leaf and an unbuilt edit is not yet visible in one. This index is different: it is
* generated by SCANNING library/ directly, with no build step in between. So the honest ancestor
* is the newest library/ source file. ★ THE ANCESTOR IS WHATEVER THE GENERATOR ACTUALLY READS —
* copying the other gate's choice would have been wrong.
* generated by SCANNING library/ directly, with no build step in between. ★ THE ANCESTOR IS
* WHATEVER THE GENERATOR ACTUALLY READS — copying the other gate's choice would have been wrong.
*
* NEGATIVE-TESTED: `touch library/layouts/StackLayout.vue` makes this FAIL naming that file;
* `bun run library-index` returns it to PASS.
* ⚠ SCOPE NOTE, KEPT FROM THE MTIME VERSION because it still explains the generator: it reads
* exactly three subdirectories — components/, composables/ and utils/ (see its section() calls).
* 75 files are indexed, 173 are not — scripts/ 58, i18n/ 98, plus data/, layouts/, api/, constants/.
* Comparing OUTPUT rather than input mtimes makes that scope self-enforcing: a change outside those
* three directories cannot alter one byte of the generated menu, so this gate can no longer fire on
* one. The INDEXED_DIRS list is gone with the mtime walk that needed it.
*
* NEGATIVE-TESTED: deleting a `- **Name**` row from the index makes this FAIL naming that row;
* `touch`ing any library file does NOT (it is not drift); `bun run library-index` returns it to PASS.
*/
import { readdirSync, existsSync, statSync } from 'node:fs'
import { existsSync, readFileSync, mkdtempSync, mkdirSync, copyFileSync, symlinkSync, rmSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { resolve, dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const INDEX = join(ROOT, 'documentation', 'reference-library-index.md')
const REL = join('documentation', 'reference-library-index.md')
const INDEX = join(ROOT, REL)
const LIB = join(ROOT, 'library')
const GENERATOR = join(ROOT, 'internal', 'generate-library-index.mjs')
if (!existsSync(LIB)) {
console.log('LIBRARY-INDEX: SKIP — no library/ directory.')
process.exit(0)
}
if (!existsSync(GENERATOR)) {
console.log('LIBRARY-INDEX: SKIP — internal/generate-library-index.mjs is not present.')
process.exit(0)
}
if (!existsSync(INDEX)) {
console.error('LIBRARY-INDEX: FAIL — documentation/reference-library-index.md does not exist.')
console.error(' It is the menu of reusable pieces an author reads INSTEAD of rebuilding them.')
@ -38,47 +79,47 @@ if (!existsSync(INDEX)) {
process.exit(1)
}
// Newest source the generator actually scans.
//
// ★ AND THAT NOW MEANS WHAT IT SAYS (fixed 2026-09-23). This walked ALL of library/, while
// generate-library-index.mjs reads exactly three subdirectories — components/, composables/ and
// utils/ (see its section() calls). MEASURED: 75 files are indexed, 173 are not — scripts/ 58,
// i18n/ 98, plus data/, layouts/, api/, constants/. So the gate watched 2.3x more than the thing it
// guards, and a change anywhere in that surplus demanded a regeneration that could not alter one
// byte of the output.
//
// It fired for real: adding a COMMENT to library/scripts/research/build-trade-languages.mjs turned
// this gate red, naming a build script as the reason the component MENU was stale. A gate that
// fails for a change it can prove is irrelevant teaches the reader to regenerate on reflex, which
// is how a staleness check stops being read.
//
// Keeping the scope in step is the price of this design: if generate-library-index.mjs ever adds a
// section(), add its directory here in the same commit.
const INDEXED_DIRS = ['components', 'composables', 'utils']
let newest = 0, newestFile = null
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(d, e.name)
if (e.isDirectory()) { walk(p); continue }
if (!/\.(vue|js|mjs)$/.test(e.name)) continue
const m = statSync(p).mtimeMs
if (m > newest) { newest = m; newestFile = p.slice(ROOT.length + 1) }
// Regenerate into a throwaway tree: real script, real sources (symlinked), disposable output.
let fresh = null
let tmp = null
try {
tmp = mkdtempSync(join(tmpdir(), 'libidx-'))
mkdirSync(join(tmp, 'internal'))
mkdirSync(join(tmp, 'documentation'))
copyFileSync(GENERATOR, join(tmp, 'internal', 'generate-library-index.mjs'))
symlinkSync(LIB, join(tmp, 'library'), 'dir')
execFileSync(process.execPath, [join(tmp, 'internal', 'generate-library-index.mjs')], { stdio: 'pipe' })
fresh = readFileSync(join(tmp, REL), 'utf8')
} catch (e) {
// SKIP, never fail, if the harness itself could not run — a gate that goes red because a
// temp directory was unwritable reports a product problem that is not one.
console.log(`LIBRARY-INDEX: SKIP — could not regenerate for comparison (${String(e.message || e).slice(0, 90)}).`)
process.exit(0)
} finally {
if (tmp) { try { rmSync(tmp, { recursive: true, force: true }) } catch { /* best effort */ } }
}
const onDisk = readFileSync(INDEX, 'utf8')
const rows = (s) => s.split('\n').filter((l) => l.startsWith('- **'))
console.log(`LIBRARY-INDEX: ${rows(onDisk).length} catalogued item(s) vs a fresh regeneration.`)
if (onDisk !== fresh) {
const a = onDisk.split('\n')
const b = fresh.split('\n')
const diffs = []
for (let i = 0; i < Math.max(a.length, b.length) && diffs.length < 8; i++) {
if (a[i] !== b[i]) diffs.push({ line: i + 1, file: a[i] ?? '(end of file)', real: b[i] ?? '(end of file)' })
}
}
for (const d of INDEXED_DIRS) {
const p = join(LIB, d)
if (existsSync(p)) walk(p)
}
const idxMs = statSync(INDEX).mtimeMs
console.log(`LIBRARY-INDEX: index vs ${newestFile ? 'newest library source' : 'library/'}.`)
if (newest > idxMs) {
console.error(`\nLIBRARY-INDEX: FAIL — the derived menu is OLDER than library/.`)
console.error(` ✗ ${newestFile} is ${Math.round((newest - idxMs) / 1000)}s newer than the index`)
console.error('\nLIBRARY-INDEX: FAIL — the derived menu no longer matches library/.')
for (const d of diffs) {
console.error(` line ${d.line}`)
console.error(` file: ${d.file}`)
console.error(` real: ${d.real}`)
}
const onlyCount = Math.abs(rows(onDisk).length - rows(fresh).length)
if (onlyCount) console.error(` (${onlyCount} catalogued row(s) differ in number)`)
console.error('\n The file says "DERIVED — cannot drift from the code"; that is only true if it is')
console.error(' regenerated. Fix: bun run library-index')
console.error(' regenerated. Fix: bun run library-index (then commit the regenerated file).')
process.exit(1)
}
console.log('LIBRARY-INDEX: PASS — the derived menu is newer than every library/ source.')
console.log('LIBRARY-INDEX: PASS — the derived menu matches what library/ generates today.')

View file

@ -0,0 +1,98 @@
// check-locale-reachable — a reader must be able to CHOOSE one of the 11 locales.
//
// ★ THE BUG THIS EXISTS FOR IS UNREACHABILITY, not incorrectness. The packet ships
// in 11 languages and every string is translated — and detectLocale() read ONLY
// document.documentElement.lang, which EVERY generated leaf page in this repo
// hardcodes to "en" (measured: 20 of 20). On the built static tree — the jamstack
// case these bundles exist for — ten of the eleven translations could not be
// reached by any reader. Correct, complete, and invisible.
//
// So this asserts two separate things, because they fail separately:
// 1. RESOLUTION — ?lang= / ?locale= selects a supported locale, a bad value falls
// back to English, and a region tag (pt-BR) resolves to its base language.
// Tested against the SHIPPED source of detectLocale, not a copy.
// 2. REACHABILITY — the built leaf pages still declare a lang, so the host-page
// path (Polylang on WordPress) keeps working. If someone later makes the pages
// lang-less, the URL path alone would silently become the only way in.
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO
// FOURTH instance of this pattern in one flight (check-zone-census, check-share-policy,
// check-poster-prints, now this). DOXA_REPO still wins; only the default becomes portable,
// per the idiom check-no-absolute-paths.mjs prints in its own failure text. A gate that
// hardcodes its author's worktree turns build:unlocked red for every other builder.
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const src = readFileSync(join(REPO, 'library/i18n/index.js'), 'utf8')
const SUPPORTED = (src.match(/SUPPORTED_LOCALES = \[([^\]]*)\]/) || [])[1]
?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) || []
// Evaluate the SHIPPED detectLocale rather than reimplementing it — a gate that
// reimplements the thing it tests only ever proves the copy agrees with itself.
const body = src.slice(src.indexOf('export function detectLocale'), src.indexOf('export function createAppI18n'))
const make = new Function('SUPPORTED_LOCALES', 'window', 'document',
`${body.replace('export function', 'return function')}\n; return detectLocale;`)
const detect = (search, htmlLang) =>
make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })()
const CASES = [
['?lang=es', 'en', 'es', 'a lang param selects a supported locale'],
['?locale=fr', 'en', 'fr', 'the locale spelling works too'],
['?lang=pt-BR', 'en', 'pt', 'a region tag resolves to its base language'],
['?lang=zz', 'en', 'en', 'an unsupported value falls back to English'],
['?lang=', 'de', 'de', 'an empty param defers to the host page'],
['', 'de', 'de', 'with no param the host page still wins'],
['?lang=ar', 'en', 'ar', 'RTL locales are selectable like any other'],
]
const fails = []
for (const [q, htmlLang, want, why] of CASES) {
let got
try { got = detect(q, htmlLang) } catch (e) { got = `THREW: ${e.message}` }
if (got !== want) fails.push(`${q || '(no param)'} with html lang="${htmlLang}" -> "${got}", expected "${want}" (${why})`)
}
// Every packet language must be reachable: the two lists cannot drift apart.
const strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
const packetLangs = Object.keys(strings).filter((k) => !k.startsWith('_'))
const unreachable = packetLangs.filter((l) => !SUPPORTED.includes(l))
if (unreachable.length) {
fails.push(`the packet ships ${unreachable.join(', ')} but SUPPORTED_LOCALES does not list them — `
+ 'those translations cannot be selected')
}
// The host-page path must survive: leaf pages still declare a lang.
const leafRoot = join(REPO, 'public/doxa-maps')
let leaves = 0
let withLang = 0
if (existsSync(leafRoot)) {
for (const bundle of readdirSync(leafRoot)) {
const bdir = join(leafRoot, bundle)
let subs = []
try { subs = readdirSync(bdir) } catch { continue }
for (const sub of subs) {
const page = join(bdir, sub, 'index.html')
if (!existsSync(page)) continue
leaves += 1
if (/<html[^>]*\blang=/.test(readFileSync(page, 'utf8'))) withLang += 1
}
}
}
if (leaves && withLang !== leaves) {
fails.push(`${leaves - withLang} of ${leaves} leaf pages declare no lang — `
+ 'the host-page detection path is broken for them')
}
console.log(` supported locales : ${SUPPORTED.length} (${SUPPORTED.join(' ')})`)
console.log(` packet languages : ${packetLangs.length} (${packetLangs.join(' ')})`)
console.log(` resolution cases : ${CASES.length - fails.filter((f) => f.includes('->')).length}/${CASES.length} correct`)
console.log(` leaf pages w/ lang: ${withLang}/${leaves}`)
if (fails.length) {
console.error(`\nLOCALE-REACHABLE: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nLOCALE-REACHABLE: PASS — all ${packetLangs.length} packet languages are selectable.`)

View file

@ -8,7 +8,7 @@
* discovered but MISSING .... 1 key-cities
* in manifest, not real ..... 1 template-bundle
* `key-cities` had its compiled key-cities.js on disk but ZERO profile leaf directories, so its four
* profiles vanished from manifest.json and from the studio's tab list. The product owner was reviewing a tree
* profiles vanished from manifest.json and from the studio's tab list. The agent was reviewing a tree
* that silently omitted a whole plugin.
*
* ★ WHY EVERY EXISTING GATE PASSED — this is the interesting part, and the reason for a new file:

View file

@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* check-maplibre-layer-drops.mjs — MapLibre must not SILENTLY DROP a layer.
*
* THE DEFECT CLASS, found twice in one flight and invisible to every other gate:
* MapLibre validates a layer spec and, if any property is unknown or misplaced, REJECTS THE WHOLE
* LAYER. addLayer() does NOT throw — it accepts the call, emits an `error` EVENT on the map, and
* silently omits the layer. getLayer() then returns undefined and the map renders without it. So
* the symptom is "a thing is missing", with no exception, no failed request, and no console error
* from the app's own code. Three ticks of instrumenting addLayer() found nothing, because the
* failure never travels through addLayer's return path.
*
* MEASURED instances, both real and both shipped:
* layers.regions-fill.paint.fill-emissive-strength: unknown property
* -> *-emissive-strength is a Mapbox GL v3 LIGHTING property. The WAGF region fills were
* absent on every MapLibre map while Mapbox showed them.
* layers.apg-pins.paint.circle-sort-key: unknown property "circle-sort-key"
* -> circle-sort-key is a LAYOUT property, written into paint. Mapbox tolerated the
* misplacement; MapLibre dropped the layer, so ALL 12,379 pins on all-people-groups
* vanished under the keyless engine.
*
* WHAT IT ASSERTS: load every isMap leaf in the built manifest with ?provider=maplibre, listen to
* the map's own `error` event, and fail on any message in the layer/source-dropping family
* (unknown property / does not exist / is required / Expected / invalid). Other errors are
* REPORTED but not failed — a tile 404 is a different problem and should not be smuggled in here.
*
* ★ IT IS A REAL CONTROL, NOT DECORATION: run against the tree before the fixes it reports
* 1 of 17 leaves dropping, and 0 of 17 after. It has been seen both red and green on the same
* codebase, which is the only thing that makes a green meaningful.
*
* SKIPS CLEANLY (exit 0) when the server is unreachable. An UNMEASURED check is not a FAILED one —
* check:filter-width prints "FAIL" when it cannot reach its server, and that is how a gate teaches
* people to ignore it.
*
* Usage: bun run serve (or any server on BASE_URL), then:
* node internal/check-maplibre-layer-drops.mjs
* BASE_URL=http://127.0.0.1:4399 node internal/check-maplibre-layer-drops.mjs
*/
import { chromium } from 'playwright'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
// Portable by construction — see check-no-absolute-paths.mjs. Never hardcode a worktree.
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const BASE = String(process.env.BASE_URL || 'http://127.0.0.1:4173').replace(/\/+$/, '')
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
const DROP_RE = /unknown property|does not exist|is required|Expected|invalid/i
if (!existsSync(MANIFEST)) {
console.log('MAPLIBRE-LAYER-DROPS: SKIP — no built manifest (run `bun run tree` first).')
process.exit(0)
}
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const leaves = []
for (const b of manifest.bundles || []) {
for (const p of b.profiles || []) if (p.isMap) leaves.push([b.bundle, p.profile])
}
if (!leaves.length) {
console.log('MAPLIBRE-LAYER-DROPS: SKIP — the manifest declares no isMap leaf.')
process.exit(0)
}
// Capture the map's error events by wrapping whichever engine global the bundle loads at runtime.
const INIT = () => {
window.__mapErrs = []
const wrap = (ns) => {
if (!ns || ns.__wrappedForDrops) return ns
const Orig = ns.Map
function Patched(...a) {
const m = new Orig(...a)
try {
m.on('error', (ev) => {
const t = String((ev && ev.error && ev.error.message) || (ev && ev.message) || '')
if (t) window.__mapErrs.push(t.slice(0, 160))
})
} catch (_) { /* an engine without .on must not break the page */ }
return m
}
Patched.prototype = Orig.prototype
ns.Map = Patched
ns.__wrappedForDrops = true
return ns
}
for (const name of ['maplibregl', 'mapboxgl']) {
let v
Object.defineProperty(window, name, {
configurable: true, get() { return v }, set(nv) { v = wrap(nv) },
})
}
}
console.log(`MAPLIBRE-LAYER-DROPS: ${leaves.length} isMap leaf/leaves at ${BASE}`)
const browser = await chromium.launch({
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'],
})
const offenders = []
let unreachable = 0
for (const [bundle, profile] of leaves) {
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } })
await page.addInitScript(INIT)
const url = `${BASE}/doxa-maps/${bundle}/${profile}/index.html?provider=maplibre`
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 })
await page.waitForTimeout(9000)
const errs = await page.evaluate(() => [...new Set(window.__mapErrs || [])])
const drops = errs.filter((e) => DROP_RE.test(e))
if (drops.length) {
offenders.push({ leaf: `${bundle}/${profile}`, drops })
console.log(` ✗ ${bundle}/${profile} — ${drops[0]}`)
} else {
console.log(` ok ${bundle}/${profile}`)
}
} catch (e) {
unreachable++
console.log(` · ${bundle}/${profile} — UNMEASURED (${String(e.message).slice(0, 60)})`)
}
await page.close()
}
await browser.close()
if (unreachable === leaves.length) {
console.log('MAPLIBRE-LAYER-DROPS: SKIP — nothing was reachable at ' + BASE + '. Start `bun run serve`.')
process.exit(0)
}
if (offenders.length) {
console.error(`\nMAPLIBRE-LAYER-DROPS: FAIL — ${offenders.length} leaf/leaves silently drop a layer under MapLibre.`)
for (const o of offenders) for (const d of o.drops) console.error(` ${o.leaf}: ${d}`)
console.error('\n MapLibre rejects a whole layer for ONE unknown or misplaced property, without throwing.')
console.error(' Engine-only paint (e.g. *-emissive-strength) belongs behind emissivePaint(map, kind);')
console.error(' layout properties (e.g. circle-sort-key) belong in `layout`, not `paint`.')
process.exit(1)
}
console.log(`\nMAPLIBRE-LAYER-DROPS: PASS — ${leaves.length - unreachable} leaf/leaves, no layer dropped by MapLibre.`)

View file

@ -0,0 +1,91 @@
// check-mfe-two-tab — a two-tab MFE must actually MOUNT BOTH PANES and switch.
//
// The failure this catches is BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: every layer
// inside these composites is position:absolute/height:100%, so the whole chain
// resolves to ZERO unless :host is sized. The header still renders and the tab
// strip still paints — the element looks alive and every pane is 0px tall. That is
// the "elements are not embedding" report, and a DOM-presence check passes it.
// So this asserts MEASURED pane height, not mere existence.
//
// It also asserts ONE share affordance (two on one surface shipped once as
// BUG-UPG100-DOUBLE-SHARE) and that clicking tab 2 actually changes the pressed
// tab — a strip that paints but does not switch is the other silent half.
//
// Usage: node internal/check-mfe-two-tab.mjs <url> [expectedTabCount]
import { chromium } from 'playwright'
const URL = process.argv[2]
if (!URL) { console.error('usage: check-mfe-two-tab.mjs <url> [tabs]'); process.exit(2) }
const WANT_TABS = Number(process.argv[3] || 2)
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
const pg = await b.newPage({ viewport: { width: 1280, height: 900 } })
const errs = []
pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140)))
await pg.goto(URL, { waitUntil: 'load', timeout: 45000 })
await pg.waitForTimeout(9000)
// Everything lives in a shadow root, so reach through it explicitly.
const r = await pg.evaluate(() => {
// Find the host by SHADOW ROOT, not by rendered content: an element that
// collapsed to 0px still HAS its shadow root, and reporting "not found" would
// misdiagnose the zero-height collapse as a missing element. Measured — removing
// :host produced exactly that misleading message.
const host = [...document.querySelectorAll('*')].find((e) => e.shadowRoot
&& (e.shadowRoot.querySelector('.viewsel, .pane-col, [role="tab"]')
|| /mfe/i.test(e.tagName)))
if (!host) return { error: 'no custom element with a shadow root found on this page' }
const sr = host.shadowRoot
// ⚠ Select the TABS, not their container. '.viewsel .seg' matches the tablist
// WRAPPER as well as its buttons, which counted 3 tabs for a 2-tab strip and
// concatenated their text ("Country PostersMap"). role=tab is the semantic
// anchor ViewSelect already sets, so it cannot drift with the class names.
const tabs = [...sr.querySelectorAll('[role="tab"]')]
.map((t) => (t.textContent || '').trim()).filter(Boolean)
const panes = [...sr.querySelectorAll('.pane')].map((p) => {
const box = p.getBoundingClientRect()
const mounted = !!p.querySelector('.pane-mount > *')
return { h: Math.round(box.height), w: Math.round(box.width), mounted }
})
const shares = sr.querySelectorAll('[aria-label*="hare"], .share-btn, .sharebtn').length
const hostBox = host.getBoundingClientRect()
return { tabs, panes, shares, hostH: Math.round(hostBox.height) }
})
await b.close()
if (r.error) { console.error(`MFE-TWO-TAB: FAIL — ${r.error}`); process.exit(1) }
const fails = []
if (r.tabs.length !== WANT_TABS) fails.push(`${r.tabs.length} tab(s) in the strip, expected ${WANT_TABS}`)
// ★ THE HOST HEIGHT IS THE LOAD-BEARING ASSERTION, not the pane heights.
// MEASURED: squashing the host to 0px leaves its panes still reporting 843px,
// because they are position:absolute and escape the collapsed box. So a
// pane-height check alone does NOT catch BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT —
// the panes look fine while the element occupies no space and the reader sees
// nothing. Only the host measurement reveals it.
if (r.hostH < 100) {
fails.push(`the host element measured ${r.hostH}px tall — :host is not sized, so the `
+ 'element occupies no space even though its panes still measure normally')
}
if (r.panes.length !== WANT_TABS) fails.push(`${r.panes.length} pane(s) mounted, expected ${WANT_TABS}`)
for (const [i, p] of r.panes.entries()) {
if (!p.mounted) fails.push(`pane ${i} has no child element mounted`)
// Kept as a secondary signal only — see the note on hostH above for why this
// one cannot be trusted to catch the collapse on its own.
if (p.h < 100) fails.push(`pane ${i} measured ${p.h}px tall`)
}
if (r.shares > 1) fails.push(`${r.shares} share affordances on one surface — exactly one belongs here`)
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
console.log(` host height : ${r.hostH}px`)
console.log(` tabs : ${r.tabs.join(' | ') || '(none)'}`)
console.log(` panes : ${r.panes.map((p) => `${p.w}x${p.h}${p.mounted ? '' : ' UNMOUNTED'}`).join(', ')}`)
console.log(` share buttons : ${r.shares}`)
console.log(` page errors : ${errs.length}`)
if (fails.length) {
console.error(`\nMFE-TWO-TAB: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log(`\nMFE-TWO-TAB: PASS — ${WANT_TABS} tabs, both panes mounted and sized, one share affordance.`)

View file

@ -4,7 +4,7 @@
*
* THE BUG THIS EXISTS TO PREVENT (real, found 2026-09-22 by resizing the cap-5 SPA to 414px).
* VerticalTabsLayout renders its drawer toggle inside `<header v-if="header">`. The cap-5 DX-Maps
* SPA runs header-OFF by default (the product owner's no-stacked-headers rule for embedding). At mobile
* SPA runs header-OFF by default (the agent's no-stacked-headers rule for embedding). At mobile
* width the rail becomes an off-canvas drawer at translateX(-100%) with navOpen=false. So: the
* drawer was off screen, the header holding the toggle did not exist, and the only other control
* (.vtl-collapse) lives INSIDE the drawer — measured off screen at x:-244.

View file

@ -3,10 +3,10 @@
* check-no-orphan-gates.mjs — a gate that nothing can run is not a gate.
*
* THE DEFECT THIS CATCHES. `internal/verify-shell-scroll.mjs` was written, negative-tested, and
* proved a real product owner-reported bug with hard numbers (2586px of mounted grid, a real wheel gesture
* proved a real agent-reported bug with hard numbers (2586px of mounted grid, a real wheel gesture
* moving 0px). Then it sat unreachable: no `package.json` entry, no importer. It would never have
* run again. The repo has already carded this once —
* a tracked issue — and it recurred anyway, because
* BUILD-BROWSER-GATES-WERE-ALL-ORPHANS-ONE-COMMAND-RUNS-THEM — and it recurred anyway, because
* writing a gate and REGISTERING a gate are two separate acts and only the first feels like work.
*
* ★ THE DISTINCTION THIS GATE HAS TO GET RIGHT, or it becomes noise nobody reads: not every
@ -101,12 +101,45 @@ if (orphans.length) {
console.error(' array of internal/verify-browser-all.mjs so the one-command suite runs it);')
console.error(' 2. or add `@one-shot` to its header if it was a diagnostic for a single bug and')
console.error(' is kept only as a reproduction recipe.')
console.error('\n This already happened once (tracked issue). It recurs')
console.error('\n This already happened once (card BUILD-BROWSER-GATES-WERE-ALL-ORPHANS). It recurs')
console.error(' because writing a gate and registering it are separate acts, and only the first')
console.error(' one feels like the work.')
process.exit(1)
}
// ── SECOND, DISTINCT QUESTION: does it RUN? ────────────────────────────────
// Reachable and wired are not the same property, and conflating them is how 70 of
// 100 check:* scripts came to sit outside every gate wave without anyone noticing —
// including this gate itself. A script entry means a human CAN run it; membership in
// gates:pre/gates:post means it runs on every build.
//
// This REPORTS rather than fails, deliberately. Most unwired gates are correctly
// hand-run: they need a served tree (check:mobile, check:shots), a device, a token,
// or an argument (check:packet-render takes a path). Failing the build on those
// would be wrong and the gate would be disabled within a day.
//
// ⚠ AND SOME MUST NEVER BE WIRED, which "it passes when I run it" cannot tell you.
// check:settled asserts the tree is QUIET — no builder running, no recent churn — so
// it fails inside a build BY DESIGN. Measured 2026-09-24: it passed twice standalone,
// was wired on that evidence, and failed the very next build (29/30). The test for
// wave membership is not "does it pass" but "is its assertion compatible with running
// mid-build". What it must not do is
// stay silent — a gate that could run and does not is indistinguishable from one
// that is broken, which is the exact failure verify-shell-scroll had.
const pkg = JSON.parse(pkgRaw)
const waves = `${pkg.scripts['gates:pre'] || ''} ${pkg.scripts['gates:post'] || ''}`
const checks = Object.keys(pkg.scripts).filter((k) => /^(check|verify|smoke):/.test(k)
&& !k.endsWith(':self-test'))
const unwired = checks.filter((k) => !waves.includes(k))
console.log(` wave membership : ${checks.length - unwired.length}/${checks.length} `
+ `check/verify script(s) run in gates:pre or gates:post`)
if (unwired.length) {
console.log(` ${unwired.length} run only by hand — expected for gates needing a served tree, a`)
console.log(' device, a token or an argument. If one of these needs no precondition, wire it:')
for (const k of unwired.slice(0, 8)) console.log(` · ${k}`)
if (unwired.length > 8) console.log(` …and ${unwired.length - 8} more`)
}
console.log(`NO-ORPHAN-GATES: PASS — ${checked} gate-shaped file(s); all reachable (${tagged} tagged @one-shot).`)
console.log(' Reachable means named in package.json or imported by something that is. The @one-shot')
console.log(' tag exists so a dated diagnostic can be KEPT as a reproduction recipe without either')

View file

@ -15,8 +15,8 @@
* instructions to an agent. The actual behaviour lived in a FLAG in package.json. Prose cannot
* constrain a flag. The rule was never violated by a decision — it was violated by a default that
* nobody re-read, and it stayed violated because nothing ever asked the question out loud. The
* product owner stated it plainly: "we don't need any open ports on this box because you push your changes
* to the forge and I pull them from my laptop... use Playwright for yourself internally for testing,
* agent stated it plainly: "we don't need any open ports on this box because you push your changes
* to the git forge and I pull them from my laptop... use Playwright for yourself internally for testing,
* not for public access."
*
* Loopback is sufficient for the entire test suite: verify-browser-all, check:a11y-button-names,
@ -82,7 +82,7 @@ if (errs.length) {
console.error('NO-PUBLIC-BINDS: FAIL — a repo script would open a port to the network.\n')
errs.forEach((e) => console.error(e))
console.error('\n The whole test suite reaches its server over 127.0.0.1. Nothing here needs a public')
console.error(' bind: code moves between machines through the forge, not through an open port. Use')
console.error(' bind: code moves between machines through the git forge, not through an open port. Use')
console.error(' --bind 127.0.0.1, and pass BASE_URL/BASE if some caller genuinely needs another host.')
process.exit(1)
}

View file

@ -0,0 +1,146 @@
// check-packet-browser — the packet the READER gets, not the one Node writes.
//
// WHY A SECOND PACKET GATE. check-packet-render verifies a packet FILE produced by
// the build-time generator. This one verifies the BROWSER path: the legend's
// Export > Prayer packet action imports library/packet/packet-render.js, fetches
// live records, and prints in-place. Both callers share that one renderer, and this
// gate is what proves the shared module actually runs in a browser — a module can
// be perfectly correct under Node and still fail in a tab (a bare `process.env`
// reference, a Node-only import, a JSON import assertion).
//
// It also covers I18N, which is a product requirement rather than a nicety: pass a
// language and the gate reports which headings came back translated. Fallback is
// PER KEY, so a half-finished translation must render its finished keys and leave
// the rest in English — never blank.
//
// Usage: node internal/check-packet-browser.mjs [lang]
// PACKET_ORIGIN http origin serving the repo root (default http://127.0.0.1:4898)
// PACKET_API records API (default https://pray.doxa.life)
// PACKET_ISO3 country to build a test packet for (default LAO)
// Needs a static server over the repo root, e.g.: python3 -m http.server 4898
import { chromium } from 'playwright'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const b = await chromium.launch()
const pg = await b.newPage({ viewport:{width:1500,height:1941} })
const errs=[]; pg.on('pageerror',e=>errs.push(String(e).slice(0,140)))
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const API = process.env.PACKET_API || 'https://pray.doxa.life'
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
const RENDER = `${ORIGIN}/library/packet/packet-render.js`
const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
// The SAME catalog, read from disk for the assertions below. STRINGS is the URL the
// browser fetches; this is the local path, so the gate compares against the file the
// page actually loaded rather than a second copy.
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
'..', 'library/scripts/posters/packet-strings.json')
const LANG = process.argv[2] || 'en'
await pg.goto(ORIGIN + '/')
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
import(RENDER),
fetch(STRINGS).then(r=>r.json()),
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
])
const R = createPacketRenderer({ strings, lang: LANG })
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url&lang=en`, {cache:'no-store'})
// Use the SAME normalizer the app uses — the API returns { posts, total }, and
// hand-rolling the extraction is how a probe ends up testing something the
// product never does.
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
const groups = recs.filter(r=>String(r.country_code)===ISO3)
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
await pg.setContent(html, { waitUntil:'load' })
await pg.waitForTimeout(3000)
// ⚠ READ THE HEADINGS BY STRUCTURE, NOT BY ENGLISH WORDS. These were greps for
// /Wheel/ and /Prayer Points|Puntos/, so they returned EMPTY for every language
// whose heading contains neither — Spanish reported a blank wheel heading and
// Hindi blanked BOTH, while the catalog holds 'Rueda de Oración' and
// 'प्रार्थना चक्र'. Nothing asserted them, so the gate PASSED on what looked
// exactly like a product i18n failure. It was the harness. The renderer marks
// these two blocks, so select them and compare to the catalog below.
const r = await pg.evaluate(()=>({
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
// The passage block, not the English proper noun 'Gadarenes': that word would
// vanish the moment point4_passage is translated, so the assertion would go RED
// on a CORRECTLY translated packet (the same trap already fixed in
// check-packet-render).
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
firstCard: (document.querySelector('.card')||{}).innerText||'',
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
pointsTitle: (document.querySelector('.points-title')||{textContent:''}).textContent.trim(),
}))
const pdf = await pg.pdf({ preferCSSPageSize:true })
const t = pdf.toString('latin1')
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
await b.close()
console.log(` lang : ${LANG}`)
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` people cards : ${r.cards}`)
console.log(` wheel segments : ${r.wheel}`)
console.log(` Mark 5 in full : ${r.mark5}`)
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
console.log(` points heading : ${r.pointsTitle.trim()}`)
console.log(` wheel heading : ${r.wheelTitle.trim()}`)
console.log(` page errors : ${errs.length} ${errs[0]||''}`)
// ── NAMED ASSERTIONS, NOT ONE COLLAPSED BOOLEAN ─────────────────────────────
// This was `const bad = a || b || c || ...`, so a failure printed "FAIL" without
// saying WHICH of six conditions tripped. Three further metrics were printed and
// never checked at all: page size, and the two headings — which are the very
// strings this gate's own header says it covers ("the gate reports which headings
// came back translated"). Reports, not asserts.
const fails = []
if (!r.sheets) fails.push('no .sheet elements — the browser packet did not render')
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be one page`)
if (!r.cards) fails.push('no people-group cards')
if (r.wheel !== 12) fails.push(`prayer wheel has ${r.wheel} segments, expected 12`)
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
// Mark 5:1-20 in full, by LENGTH rather than an English proper noun.
if (r.mark5Len < 1200) {
fails.push(r.mark5Len === 0
? 'Mark 5:1-20 is NOT printed (no .p4-passage block)'
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars`)
}
// PAGE SIZE was printed and never compared. `@page{size:letter portrait}` is
// 612x792pt; a wrong PORTRAIT size (A4 is 595x842) is taller-than-wide too, so
// nothing else here would notice it.
if (!boxes.length) fails.push('the PDF declares no MediaBox — page geometry unreadable')
else {
const wrong = boxes.filter(([w,h]) => Math.abs(w-612)>1 || Math.abs(h-792)>1)
if (wrong.length) fails.push(`${wrong.length}/${boxes.length} page(s) are ${wrong[0].join(' x ')}pt, expected 612 x 792pt`)
}
// ── I18N, ASSERTED AGAINST THE CATALOG ──────────────────────────────────────
// The heading a reader sees must equal the catalog entry for THIS packet's
// language — per-key fallback to English when the key is absent, exactly as the
// renderer resolves it. Never a hardcoded expectation, which is what made these
// two unassertable in the first place.
const cat = JSON.parse(readFileSync(STRINGS_PATH, 'utf8'))
const expect = (key) => ((cat[LANG]||{})[key] ?? cat.en[key])
for (const [sel, key, got] of [['points-title','points_title',r.pointsTitle],
['wheel-title','wheel_title',r.wheelTitle]]) {
const want = expect(key)
if (!got) fails.push(`.${sel} is EMPTY — a blank heading is worse than an English one`)
else if (got !== want) fails.push(`.${sel} reads ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`)
}
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
if (fails.length) {
console.error(`\nPACKET-BROWSER: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPACKET-BROWSER: PASS — the browser-rendered packet is correct, and its headings '
+ `match the ${LANG} catalog.`)

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