From 82f13489625db90cfeccc79ffcdec8d23930a509 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 04:45:26 +0000 Subject: [PATCH] Update from source b23ecd4f6 --- deployment/cloud/README.md | 1 + deployment/cloud/admin.mjs | 35 +++ internal/check-series-isolation.mjs | 202 ++++++++++++++++++ library/scripts/posters/generate.mjs | 42 +++- library/scripts/posters/packet.mjs | 46 +++- library/scripts/posters/series.json | 105 +++++++++ package.json | 1 + .../gospel-internet/board.meta.json | 2 +- public/dx-maps/doxa-maps-spa/manifest.json | 2 +- public/dx-maps/manifest.json | 2 +- 10 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 internal/check-series-isolation.mjs create mode 100644 library/scripts/posters/series.json diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index 0de8d5a5..ed205bd3 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -68,6 +68,7 @@ there. Only then does it swap the new build in. | `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https:///pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https:///pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` | | `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` | | `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) | +| `SUBDOMAIN_APPS` | publish plugins as their own PUBLIC sites: `host=bundle/profile`, comma-separated, e.g. `research-gallery.example.org=doxa-maps-spa/doxa-maps-spa`. Served from the `/cdn` build by a separate server on `SUBDOMAIN_PORT` (default `5176`) that answers only those hosts and only with published files; point each host's proxy route at that port. No sign-in is skipped anywhere else | | `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https:///api-prod`) | | `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) | | `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index f1559359..3da6f5c6 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -5,6 +5,7 @@ // its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections // /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET. // The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2). +// SUBDOMAIN_APPS public subdomain sites for published plugins, on a separate port (see below) // /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are // Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only. import { spawn } from 'node:child_process' @@ -65,6 +66,40 @@ if (AUTO_MIN > 0) { console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`) } +// SUBDOMAIN APPS: a published plugin as its own public site, e.g. research-gallery. → the /cdn build +// of doxa-maps-spa. SUBDOMAIN_APPS = "host=bundle/profile,host2=bundle/profile". A SEPARATE server on its own +// port, reached by the proxy only for those hosts: it serves the published /cdn files and nothing else (no +// admin, no PocketBase, no dev server), so no sign-in is skipped anywhere else. "/" is the plugin's page with a +// at its real folder, so its relative links resolve exactly as they do on /cdn. +const SUBDOMAINS = Object.fromEntries((env.SUBDOMAIN_APPS || '').split(',').map((x) => x.trim()).filter(Boolean) + .map((x) => x.split('=').map((y) => y.trim())).filter(([h, t]) => h && /^[a-z0-9-]+\/[a-z0-9-]+$/.test(t || ''))) +if (Object.keys(SUBDOMAINS).length) { + Bun.serve({ + hostname: '0.0.0.0', + port: Number(env.SUBDOMAIN_PORT || 5176), + idleTimeout: 0, + async fetch(req) { + if (req.method !== 'GET' && req.method !== 'HEAD') return text('read-only', 405) + const target = SUBDOMAINS[(req.headers.get('host') || '').split(':')[0].toLowerCase()] + if (!target) return text('not found', 404) + const url = new URL(req.url) + const headers = { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=300, stale-while-revalidate=86400' } + if (url.pathname === '/' || url.pathname === '/index.html') { + const leaf = join(CDN, 'dx-maps', target, 'index.html') + if (!existsSync(leaf)) return text('this app is not published yet', 503) + const html = readFileSync(leaf, 'utf8').replace(/]*)>/i, ``) + return new Response(html, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } }) + } + const f = normalize(join(CDN, decodeURIComponent(url.pathname))) + if (!f.startsWith(CDN + '/dx-maps/')) return text('not found', 404) + const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f + if (!existsSync(file)) return text('not found', 404) + return new Response(Bun.file(file), { headers }) + }, + }) + console.log(`[admin] subdomain apps: ${Object.entries(SUBDOMAINS).map(([h, t]) => `${h} → ${t}`).join(', ')}`) +} + Bun.serve({ hostname: '127.0.0.1', port: 5175, diff --git a/internal/check-series-isolation.mjs b/internal/check-series-isolation.mjs new file mode 100644 index 00000000..db69b5be --- /dev/null +++ b/internal/check-series-isolation.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * check-series-isolation — a packet/poster must not carry ANOTHER series' framing. + * + * ★ THE DEFECT. The country posters shipped rendering the UPG-100 packet: the kicker "UNENGAGED + * PEOPLE GROUPS", the line "This packet is 1 of {n} UPG-100 lists", the whole Project 2000/7000/ + * 4700/700 narrowing funnel, and a below-map block telling the reader to divide the set among + * THIRTY disciples — coherent for ~30 UPG-100 zones, incoherent for ~170 countries. Reported by + * the agent, 2026-09-28. It was not a mislabel: NO series parameter existed anywhere, so "the + * packet" and "the UPG-100 packet" were one thing and nothing could disagree. + * + * ★ WHY A GATE AND NOT JUST THE FIX. Every one of those strings rendered perfectly — right size, + * no error, generator reporting success. The failure was that the text was FLUENT AND WRONG, which + * no build step can notice. The leak also lived in TWO files (packet-strings.json and generate.mjs's + * own NETWORKS furniture), so a fix to one leaves the other printing, silently. + * + * WHAT THIS ASSERTS — the property, not the artifact: + * 1. series.json declares every series, each with funnel / strings / networks. + * 2. SERIES=upg100 (and the DEFAULT, no env var) is UNCHANGED: its networks are byte-identical + * to the text that was hard-coded in generate.mjs, and it adds no string overrides. A + * parameter that quietly alters the existing output is a rewrite wearing a parameter's clothes. + * 3. No series' own strings/networks contain ANOTHER series' vocabulary. + * 4. A rendered packet (when one is given) carries its own series' framing and none of the other's. + * + * Usage: + * node internal/check-series-isolation.mjs # the declarations + * node internal/check-series-isolation.mjs --series=country # + a rendered file + * node internal/check-series-isolation.mjs --self-test # the rules, against known-bad input + */ + +import { readFileSync, existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = join(HERE, '..') +const SERIES_PATH = join(REPO, 'library/scripts/posters/series.json') + +const fails = [] +const fail = (m) => fails.push(m) +const ok = (m) => console.log(` · ${m}`) + +/** + * The vocabulary that BELONGS to each series. Phrases, not single words: "country" appears + * innocently all over the UPG-100 data (country_code, country names), so a bare word would fire on + * every honest string. Each entry is text that only makes sense IN that series. + * + * ⚠ "Thirty" IS SPELLED OUT, and that is the whole reason this list exists as literals rather than + * as a \b30\b regex: the original block reads "Thirty disciples", so a numeric search finds nothing + * and reports a clean tree. That search was run, and it did report clean. A quantity has to be + * looked for in BOTH forms. + */ +const VOCAB = { + upg100: [ + 'UNENGAGED PEOPLE GROUPS', + 'UPG-100', + 'Thirty disciples', + 'Thirty churches', + 'Thirty indigenous leaders', + 'Project 2000', + 'Project 7000', + 'Project 4700', + 'Project 700', + 'How this list was found', + ], + country: [ + 'PRAY FOR THIS COUNTRY', + ], +} + +/** Every string inside a value, however nested. */ +function* strings(v) { + if (typeof v === 'string') yield v + else if (Array.isArray(v)) for (const x of v) yield* strings(x) + else if (v && typeof v === 'object') for (const x of Object.values(v)) yield* strings(x) +} + +/** The text a series OWNS: its overrides and its poster furniture. Not its prose documentation. */ +function seriesText(entry) { + return [...strings(entry.strings || {}), ...strings(entry.networks || [])] +} + +function checkDeclarations(series) { + const ids = Object.keys(series).filter((k) => !k.startsWith('_')) + if (ids.length < 2) fail(`series.json declares ${ids.length} series; expected at least upg100 and country`) + else ok(`${ids.length} series declared: ${ids.join(', ')}`) + + for (const id of ids) { + const e = series[id] + if (typeof e.funnel !== 'boolean') fail(`${id}: "funnel" must be true/false (it gates a whole page)`) + if (!Array.isArray(e.networks) || e.networks.length !== 3) { + fail(`${id}: "networks" must be the 3 below-map blocks; got ${e.networks?.length}`) + } else if (e.networks.some((n) => !Array.isArray(n) || n.length !== 2 || n.some((s) => typeof s !== 'string'))) { + fail(`${id}: every networks entry must be [heading, body], both strings`) + } + if (e.strings && typeof e.strings !== 'object') fail(`${id}: "strings" must be an object of key overrides`) + if (typeof e.dataDir !== 'string' || !e.dataDir) fail(`${id}: "dataDir" must name the data folder`) + else if (!existsSync(join(REPO, e.dataDir))) fail(`${id}: dataDir "${e.dataDir}" does not exist`) + } + return ids +} + +/** + * THE DEFAULT MUST NOT HAVE MOVED. Compares series.json's upg100 networks against the text that + * was hard-coded in generate.mjs, read from git history — so this cannot be satisfied by editing + * both sides to agree. + */ +function checkDefaultUnchanged(series) { + if (Object.keys(series.upg100?.strings || {}).length) { + fail('upg100 declares string overrides; the default series must inherit packet-strings.json unchanged') + } else ok('upg100 adds no string overrides (default output unchanged)') + + if (series.upg100?.funnel !== true) fail('upg100.funnel must be true — the funnel page is its own argument') + else ok('upg100 keeps the funnel page') +} + +function checkNoCrossContamination(series, ids) { + for (const id of ids) { + const mine = seriesText(series[id]) + for (const [other, phrases] of Object.entries(VOCAB)) { + if (other === id) continue + for (const p of phrases) { + const hit = mine.find((s) => s.includes(p)) + if (hit) fail(`${id} carries ${other} vocabulary "${p}" in: ${JSON.stringify(hit.slice(0, 70))}`) + } + } + } + if (!fails.length) ok('no series carries another series\' vocabulary') +} + +/** A RENDERED file is the only proof that the wiring reached the page. */ +function checkRendered(file, id) { + const html = readFileSync(file, 'utf8') + for (const [other, phrases] of Object.entries(VOCAB)) { + if (other === id) continue + for (const p of phrases) { + if (html.includes(p)) fail(`${file}: rendered as series "${id}" but contains ${other} text "${p}"`) + } + } + const own = (VOCAB[id] || []).filter((p) => html.includes(p)) + if (!own.length) fail(`${file}: contains none of series "${id}"'s own vocabulary — is it really that series?`) + else ok(`rendered file carries its own framing (${own.length} marker(s)) and no other series'`) +} + +function selfTest() { + const good = JSON.parse(readFileSync(SERIES_PATH, 'utf8')) + const cases = [ + ['country keeps the UPG-100 kicker', (s) => { s.country.strings.map_page_kicker = 'UNENGAGED PEOPLE GROUPS' }], + ['country keeps Thirty disciples', (s) => { s.country.networks[0][0] = '1 · Thirty disciples.' }], + ['upg100 grows an override', (s) => { s.upg100.strings.map_page_kicker = 'X' }], + ['upg100 loses its funnel', (s) => { s.upg100.funnel = false }], + ['a series loses a networks block', (s) => { s.country.networks.pop() }], + ['dataDir points nowhere', (s) => { s.country.dataDir = 'no/such/folder' }], + ] + let pass = 0 + for (const [name, mutate] of cases) { + const s = JSON.parse(JSON.stringify(good)) + mutate(s) + fails.length = 0 + const ids = Object.keys(s).filter((k) => !k.startsWith('_')) + try { + checkDeclarations(s); checkDefaultUnchanged(s); checkNoCrossContamination(s, ids) + } catch { fails.push('threw') } + if (fails.length) { pass++; console.log(` ✓ caught: ${name}`) } + else console.log(` ✗ MISSED: ${name}`) + } + fails.length = 0 + if (pass !== cases.length) fail(`self-test: caught ${pass}/${cases.length} known-bad inputs`) + else ok(`self-test: caught all ${cases.length} known-bad inputs`) +} + +// ── run ────────────────────────────────────────────────────────────────────── +const args = process.argv.slice(2) +const selfTestMode = args.includes('--self-test') +const seriesArg = (args.find((a) => a.startsWith('--series=')) || '').split('=')[1] +const fileArg = args.find((a) => !a.startsWith('--')) + +console.log('SERIES-ISOLATION') +const series = JSON.parse(readFileSync(SERIES_PATH, 'utf8')) + +if (selfTestMode) { + selfTest() +} else { + const ids = checkDeclarations(series) + checkDefaultUnchanged(series) + checkNoCrossContamination(series, ids) + if (fileArg) { + const f = resolve(fileArg) + if (!existsSync(f)) fail(`rendered file not found: ${f}`) + else if (!seriesArg) fail('a rendered file needs --series= to say what it should be') + else checkRendered(f, seriesArg) + } else { + console.log(' · no rendered file given (declarations only) — pass one with --series= to check a build') + } +} + +if (fails.length) { + console.error(`\nSERIES-ISOLATION: FAIL (${fails.length})`) + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('SERIES-ISOLATION: PASS') diff --git a/library/scripts/posters/generate.mjs b/library/scripts/posters/generate.mjs index 101ad50b..74c7cc94 100644 --- a/library/scripts/posters/generate.mjs +++ b/library/scripts/posters/generate.mjs @@ -38,6 +38,31 @@ const OUT = join(__dirname, 'out'); const POSTERS = join(OUT, 'posters'); mkdirSync(POSTERS, { recursive: true }); +// ── THE SERIES ────────────────────────────────────────────────────────────── +// ★ THIS FILE IS THE HALF NOBODY WAS LOOKING AT. The agent reported the country posters carrying +// UPG-100 framing, and the search went to packet-strings.json — but the block telling the reader to +// divide the set among THIRTY disciples is not a packet string at all. It is poster furniture, a +// hard-coded array in THIS file (see NETWORKS below), so a series parameter applied to the packet +// alone would have left "Thirty disciples" printing on all ~170 country posters. +// Same series.json as packet.mjs reads, so the two surfaces cannot drift apart; unknown or absent +// SERIES resolves to upg100 so an unchanged command renders unchanged output. +// ⚠ DATA PATH IS NOT TAKEN FROM THE SERIES HERE. This file already has UPG100_DATA_PATH above, with +// its own history (forgetting it once rendered a stale copy and Nepal vanished from a poster). +// Adding a second, silently-competing source for the same path would rebuild that bug. +const SERIES_DEF_ALL = readJSON_early(join(__dirname, 'series.json')); +const SERIES_ID = process.env.SERIES || 'upg100'; +if (!SERIES_DEF_ALL[SERIES_ID] || SERIES_ID.startsWith('_')) { + const known = Object.keys(SERIES_DEF_ALL).filter((k) => !k.startsWith('_')).join(', '); + console.error(`SERIES="${SERIES_ID}" is not in series.json. Known: ${known}`); + process.exit(1); +} +// ⚠ SERIES_DEF, not SERIES. A `SERIES` already exists further down meaning the poster series' +// derived FACTS (totalGroups, totalLists, index). Two different senses of one word; the newcomer +// takes the longer name rather than shadowing a constant that predates it. +const SERIES_DEF = SERIES_DEF_ALL[SERIES_ID]; +// readJSON is declared below, after the dynamic imports; this one line cannot wait for it. +function readJSON_early(p) { return JSON.parse(readFileSync(p, 'utf8')); } + // ── the map's own logic, reused read-only ──────────────────────────────────── const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js')); const { resolveZoneGeometry } = await import(join(DATA, 'zone-geometry.js')); @@ -2169,14 +2194,15 @@ async function renderPoster(zone, pins, zoneFC) { // to run the SAME number of lines in each column — that is what makes three // columns read as one band instead of three unequal blocks, and it costs a line // of height, which goes back to the map. - const NETWORKS = [ - ['1 · Thirty disciples.', - `Each follower of Jesus takes their own map and prayer list, and sets apart a place to pray — some have hung a people-group map over their bed.`], - ['2 · Thirty churches.', - `Each church dedicates a wall in one of its places of prayer, and keeps copies of the prayer list available for distribution.`], - ['3 · Thirty indigenous leaders.', - `Find leaders on the ground in these areas and give them the map and prayer list, to raise awareness of their unengaged neighbours.`], - ]; + // ★ FROM series.json, NOT FROM HERE. These three blocks were the agent's second complaint: they + // tell the reader to divide the set among THIRTY disciples, which is coherent for ~30 UPG-100 + // zones and incoherent for ~170 country posters. The country wording is not a find-and-replace of + // "thirty" with "a hundred and seventy" — he changed the SHAPE, from "split the set" to "ask + // which one, then give them that poster". Both sets live in series.json. + // ENGLISH ONLY, on purpose: this file does no localization at all (no PACKET_LANG, no + // packet-strings, and its non-ASCII characters are entirely typographic — no translated string + // has ever been in it). The packet's 11 locales live in packet-strings.json. + const NETWORKS = SERIES_DEF.networks; const fpush = (frag) => { footBlock += frag; }; let fBot = fy; NETWORKS.forEach(([lead, body], i) => { diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index f4c65ca1..f715428e 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -41,7 +41,26 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const DATA = join(__dirname, '../../../plugin-designs/my-upg-100-list/data'); +const REPO = join(__dirname, '../../..'); + +// ── THE SERIES ────────────────────────────────────────────────────────────── +// ★ WHY THIS EXISTS. The country posters shipped rendering the UPG-100 packet: its kicker, its +// "1 of {n} UPG-100 lists" line, its narrowing funnel. Not a mislabel — there was NO series +// parameter anywhere, so "the packet" and "the UPG-100 packet" were the same thing and nothing +// could disagree. Reported by the agent, 2026-09-28. +// UNKNOWN OR ABSENT RESOLVES TO upg100, deliberately: every existing run must render byte-identical +// output with no env var set, or this refactor is a rewrite wearing a parameter's clothes. +const SERIES_ALL = JSON.parse(readFileSync(join(__dirname, 'series.json'), 'utf8')); +const SERIES_ID = process.env.SERIES || 'upg100'; +if (!SERIES_ALL[SERIES_ID] || SERIES_ID.startsWith('_')) { + const known = Object.keys(SERIES_ALL).filter((k) => !k.startsWith('_')).join(', '); + console.error(`SERIES="${SERIES_ID}" is not in series.json. Known: ${known}`); + process.exit(1); +} +const SERIES = SERIES_ALL[SERIES_ID]; +// The data folder is a SERIES property, not a constant. It was hard-wired to my-upg-100-list, +// which is the other half of why a country run could only ever be a UPG-100 run. +const DATA = join(REPO, SERIES.dataDir); const OUT = join(__dirname, 'out', 'packets'); mkdirSync(OUT, { recursive: true }); @@ -76,7 +95,24 @@ if (!API && !process.env.PACKET_RECORDS && !CACHE_OK) { } // ── STRINGS: pick the language, fall back per-key to English ──────────────── -const STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8')); +const BASE_STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8')); +// ★ SERIES OVERRIDES ARE APPLIED ONCE, HERE, OVER EVERY LOCALE BLOCK — not at each call site. +// The renderer gets `strings` as a whole and does its own per-key locale fallback, so an override +// applied only to this file's t() would render in the pages THIS file writes and not in the pages +// the RENDERER writes. One series, two different kickers, and nothing would fail. +// Overrides are ENGLISH-ONLY until the agent signs the wording off (series.json +// _draft_awaiting_driver), so they are applied to every locale ON PURPOSE: an English country +// kicker is visibly awaiting translation, whereas inheriting the translated UPG-100 kicker would +// silently print the WRONG SERIES in fluent Russian. Loud beats wrong. +const SERIES_STRINGS = SERIES.strings || {}; +const STRINGS = Object.fromEntries( + Object.entries(BASE_STRINGS).map(([loc, block]) => [ + loc, + loc.startsWith('_') || typeof block !== 'object' || block === null + ? block + : { ...block, ...SERIES_STRINGS }, + ]), +); const EN = STRINGS.en; const L = STRINGS[LANG] || {}; // Per-KEY fallback (not whole-block): a half-finished translation should render @@ -151,6 +187,12 @@ try { // only one the zone catalogue describes, so it is the only one that may claim "1 of K lists" and an // claim; a non-default list states its OWN total and goes quiet on the rest. function buildFunnel(membership) { + // ★ THE FUNNEL IS A UPG-100 ARGUMENT, so a series that is not that list does not get the page. + // It narrows every people group on earth down to THIS list (Project 2000/7000/4700/700) and ends + // "1 of {n} UPG-100 lists" — true of a UPG-100 zone, and simply not a description of a country. + // Returning null here rather than filtering later reuses the path already built for a MISSING + // vision-funnel.json: buildPacket omits the page. One way for the page to be absent, not two. + if (!SERIES.funnel) return null; try { const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8')); if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null; diff --git a/library/scripts/posters/series.json b/library/scripts/posters/series.json new file mode 100644 index 00000000..e53ec509 --- /dev/null +++ b/library/scripts/posters/series.json @@ -0,0 +1,105 @@ +{ + "_meta": { + "purpose": "What differs BETWEEN poster/packet series. One entry per series; SERIES= selects it.", + "why": [ + "The country posters shipped rendering the UPG-100 packet's framing: the 'UNENGAGED PEOPLE", + "GROUPS' kicker, 'This packet is 1 of {n} UPG-100 lists', the UPG-100 narrowing funnel, and a", + "below-map block telling the reader to divide the set among thirty disciples. Coherent for ~30", + "UPG-100 zones; incoherent for ~170 countries. Reported by the agent, 2026-09-28.", + "There was no series parameter anywhere: packet.mjs and packet-render.js knew only one series,", + "so 'the packet' and 'the UPG-100 packet' were the same thing and nothing could disagree.", + "", + "ONE mechanism for BOTH surfaces (dx-shepherd's call): packet.mjs AND generate.mjs read this", + "file, because the leak was in two independent places -- the packet strings and the poster's", + "own furniture (generate.mjs NETWORKS), which is not a packet string at all. Fixing only the", + "first would have left 'Thirty disciples' printing on every country poster." + ], + "default": "upg100 -- absent or unknown SERIES resolves to upg100, so existing runs are unchanged.", + "keys": { + "label": "human name, for logs and error messages", + "dataDir": "repo-relative folder holding clusters.json and the selector/normalize/legend modules", + "funnel": "false DROPS the narrowing-funnel page entirely (it is a UPG-100 argument)", + "strings": "overrides merged OVER packet-strings.json's block, per key", + "networks": "the three below-map blocks generate.mjs prints; ENGLISH ONLY -- see note" + }, + "english_only_note": [ + "`networks` is English-only ON PURPOSE, not an oversight. generate.mjs performs no localization", + "at all: measured zero occurrences of PACKET_LANG, packet-strings or i18n in the file, and its", + "1,728 non-ASCII characters are entirely typographic (box-drawing, em-dashes, middots) with no", + "CJK, Arabic, Cyrillic or Devanagari -- so no translated string has ever been in it", + "(verified independently by regions-plugin, 2026-09-28). The poster surface is English end to", + "end; `strings` below is the half that carries 11 locales." + ], + "translation_status": [ + "The `country` English below is a DRAFT taken from the agent's dictated shape and is NOT signed", + "off -- see _draft_awaiting_driver on that entry. NO translations are to be made from it: a", + "paraphrase multiplied by 11 locales is 11 wrong strings. When he signs off the English, the", + "strings go to a HUMAN translator via dx-shepherd, which is this file's established practice --", + "all 11 locales of packet-strings.json carry real translations, never English echoes." + ] + }, + + "upg100": { + "label": "UPG-100 — unengaged people groups, ~30 zoned lists", + "dataDir": "plugin-designs/my-upg-100-list/data", + "funnel": true, + "strings": {}, + "networks": [ + ["1 · Thirty disciples.", + "Each follower of Jesus takes their own map and prayer list, and sets apart a place to pray — some have hung a people-group map over their bed."], + ["2 · Thirty churches.", + "Each church dedicates a wall in one of its places of prayer, and keeps copies of the prayer list available for distribution."], + ["3 · Thirty indigenous leaders.", + "Find leaders on the ground in these areas and give them the map and prayer list, to raise awareness of their unengaged neighbours."] + ] + }, + + "country": { + "label": "Country — one poster per country, ~170 of them", + "dataDir": "plugin-designs/my-upg-100-list/data", + "funnel": false, + "_funnel_why": [ + "The funnel narrows every people group on earth down to THIS UPG-100 list (Project 2000/7000/", + "4700/700). That argument does not describe a country poster, and a page of true numbers about", + "the wrong thing is worse than no page. Dropped for country by dx-shepherd, 2026-09-28.", + "Dropping it also removes 12 of the 17 series-specific keys from the translation bill: a page", + "that does not render needs no locales." + ], + "_draft_awaiting_driver": [ + "THE ENGLISH BELOW IS A DRAFT, NOT APPROVED. It follows the shape the agent dictated on", + "2026-09-28 but he has not signed off the wording. regions-plugin has asked him to dictate the", + "three blocks as he wants them printed. Do not translate, print at scale, or quote as final", + "until that lands; replace this key with the approved text when it does." + ], + "strings": { + "map_page_kicker": "PRAY FOR THIS COUNTRY", + "map_page_sub": "A prayer list of {count} unreached people groups who live here", + "points_intro": "One hour with God, five minutes each. Pray these twelve for the peoples of this country.", + "christian_lede": "This is the same grid — one human life in weeks. Now each box is a share of the people in this country. The shaded boxes are the evangelical share of their population; the rest are waiting." + }, + "_strings_why": [ + "FOUR keys, not seventeen. Judged by what each string SAYS rather than by matching UPG-100", + "vocabulary: 12 of the 17 are funnel_* keys and drop with the page above; `points` -- the 12", + "prayer points -- is INVARIANT, because those points are about a PEOPLE and a country poster is", + "still praying for the peoples within it; only its framing sentence (points_intro) names the", + "packet. christian_lede is ADAPTED, not replaced: the % Christian vs lost page is just as", + "meaningful for a country, so only 'the people in this list' is reworded.", + "Everything not listed here -- pray_blessed / pray_know / pray_do, the rhythms, the wheel, the", + "list header -- is series-invariant and inherited unchanged." + ], + "networks": [ + ["1 · Disciples.", + "Ask a disciple which country God has put on their heart, then give them that country's poster to print and prepare."], + ["2 · Churches.", + "List the churches you know and ask each the same question, so every church has a poster to pray over together."], + ["3 · Indigenous leaders.", + "Ask which leaders you know who live in one of these countries, and give them their own country's map to pray for it specifically."] + ], + "_networks_why": [ + "NOT a find-and-replace of thirty with a hundred and seventy. 'Divide the set among thirty", + "disciples' is coherent at 30 and incoherent at ~170 (172 countries + China + India, per", + "emit-countries.mjs), so the agent replaced the SHAPE: not 'split the set' but 'ask which one,", + "then give them that poster'. The numbers are gone from the headings for the same reason." + ] + } +} diff --git a/package.json b/package.json index 55bec3a8..a7f02bbe 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "check:packet-fixture": "node internal/generate-packet-fixture.mjs --check", "check:packet-render": "node internal/check-packet-render.mjs", "check:packet-render:expect-funnel": "node internal/check-packet-render.mjs --expect-funnel", + "check:series-isolation": "node internal/check-series-isolation.mjs", "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packets-all": "node internal/check-packets-all.mjs", "check:parse": "node internal/inline-parse-check.mjs", diff --git a/plugin-designs/gospel-internet/board.meta.json b/plugin-designs/gospel-internet/board.meta.json index 1e2e8f88..7b81fde7 100644 --- a/plugin-designs/gospel-internet/board.meta.json +++ b/plugin-designs/gospel-internet/board.meta.json @@ -1,6 +1,6 @@ { "type": "single", - "label": "Gospel x Internet — the board", + "label": "Gospel × Internet", "config": { "pinHint": false, "urlState": "query" diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index 29ee73f3..44f38077 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -619,7 +619,7 @@ { "profile": "board", "stage": "experimental", - "label": "Gospel x Internet — the board", + "label": "Gospel × Internet", "heavy": true, "file": "board.vue", "description": "For every remaining people group: what gospel resources exist in a language they use, and how much of their country (or Indian state) is online to receive them.", diff --git a/public/dx-maps/manifest.json b/public/dx-maps/manifest.json index 29ee73f3..44f38077 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -619,7 +619,7 @@ { "profile": "board", "stage": "experimental", - "label": "Gospel x Internet — the board", + "label": "Gospel × Internet", "heavy": true, "file": "board.vue", "description": "For every remaining people group: what gospel resources exist in a language they use, and how much of their country (or Indian state) is online to receive them.",