diff --git a/internal/check-gospel-embed-contract.mjs b/internal/check-gospel-embed-contract.mjs new file mode 100644 index 00000000..3d6f072f --- /dev/null +++ b/internal/check-gospel-embed-contract.mjs @@ -0,0 +1,135 @@ +/** + * check-gospel-embed-contract.mjs — the map's URL is a PUBLIC INTERFACE, so it gets a gate. + * + * WHAT THIS IS FOR. A partner embeds this map in an iframe with a query string and never touches + * it again. Every one of those links is a promise made to somebody else's website: + * + * ?codes=ckb,bal,pbu draw only these languages, framed on them + * ?country=IND open that country's sheet + * ?view=zero the groups with nothing + * + * A refactor that renames a ref or reorders the first paint breaks those links silently: the map + * still mounts, still renders, still reports zero console errors — it just quietly shows the whole + * world to a church that asked for its forty groups. Nothing else here can see that. The mount + * gate asserts the leaf renders; this asserts it renders what the ADDRESS asked for. + * + * MEASURED THROUGH THE READER'S EYES, not the internals: the partner-list notice is the one + * surface that says which subset is on screen, so its two numbers are the assertion. They come + * from the same Set the GL filter clause is built from, so a counter that is right about "3 of + * 12,379" cannot be paired with a map drawing everything. + * + * Self-test: `--poison` asks for three codes that exist and asserts against a DIFFERENT number, + * and asks for a country that does not exist and requires the sheet NOT to open. + */ +import { chromium } from 'playwright' +import { serveReaped } from './_serve-reaped.mjs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' +import { TREE_NAME } from './_tree-name.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(HERE, '..') +const PUBLIC = resolve(ROOT, 'public') +const PORT = Number(process.env.PORT || 5397) +const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}` +const POISON = process.argv.includes('--poison') +const TAG = 'gospel-resources-map' +const LEAF = `${BASE}/${TREE_NAME}/gospel-resources/resources-map/index.html` + +/* ⚠ THE DENOMINATOR IS DOXA'S ROSTER, NOT THE PIN INDEX. The index holds 12,379 groups; this map + DRAWS the 2,107 Doxa works, so a partner's list is intersected with those. Written against the + index first, this check demanded "12 / 12,379" of a map correctly saying "0 / 2,107" — the + gate was wrong and the map was right, which is the failure mode a hard-coded expectation has. + Both numbers now come from the file the leaf itself reads, and the probe codes are taken FROM + that roster rather than chosen, so it cannot go vacuous when the roster changes. */ +const roster = JSON.parse(readFileSync(resolve(PUBLIC, TREE_NAME, 'assets/tables/doxa-people-groups.json'), 'utf8')).rows || [] +const drawn = roster.filter((r) => r.mapped && r.lat != null && r.lng != null) +const total = drawn.length +const counts = new Map() +for (const r of drawn) counts.set(r.iso639_3, (counts.get(r.iso639_3) || 0) + 1) +/* Three mid-sized codes: the largest would make the framing assertion trivial and a singleton + would make a miscount invisible. */ +const CODES = [...counts.entries()].filter(([c, n]) => c && n >= 3 && n <= 30).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([c]) => c) +const matched = CODES.reduce((n, c) => n + counts.get(c), 0) +if (!matched || CODES.length < 3) { console.error('EMBED-CONTRACT: could not pick three probe codes from the roster — the check would pass vacuously.'); process.exit(1) } + +if (process.env.EC_TRACE) console.log(`trace: codes=${CODES.join(',')} matched=${matched} total=${total}`) +let srv = null +if (!process.env.BASE_URL) srv = serveReaped(PUBLIC, PORT) +if (process.env.EC_TRACE) console.log('trace: server up') + +const read = async (page, query) => { + /* domcontentloaded + a fixed settle, NOT networkidle: this leaf streams basemap tiles for as + long as it is open, so the network never goes idle and each probe burned its 90s timeout. */ + await page.goto(`${LEAF}${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 }) + await page.waitForTimeout(12000) + return page.evaluate((tag) => { + const sr = document.querySelector(tag)?.shadowRoot + if (!sr) return { mounted: false } + const note = sr.querySelector('.gm-linklist') + const sheet = sr.querySelector('.gm-sheet, .gm-panel, [class*="sheet"]') + return { + mounted: true, + notice: note ? (note.textContent || '').replace(/\s+/g, ' ').trim() : '', + sheetText: sheet ? (sheet.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 400) : '', + text: (sr.textContent || '').replace(/\s+/g, ' ').trim(), + } + }, TAG) +} +const nums = (s) => (s.match(/[\d][\d,،٬  ]*/g) || []).map((x) => Number(x.replace(/[^\d]/g, ''))) + +let browser +const fails = [] +try { + browser = await chromium.launch() + if (process.env.EC_TRACE) console.log('trace: browser up') + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) + + /* 1 — a partner's list */ + const want = POISON ? matched + 7 : matched + const a = await read(page, `?codes=${CODES.join(',')}`) + if (!a.mounted) fails.push('?codes: the leaf did not mount') + else if (!a.notice) fails.push('?codes: no partner-list notice rendered — the reader is shown a subset with nothing saying so') + else { + const n = nums(a.notice) + if (n[0] !== want || n[1] !== total) fails.push(`?codes: notice reads "${a.notice}" — expected ${want} / ${total}`) + else console.log(` ok ?codes=${CODES.join(',')}`.padEnd(34) + `notice "${a.notice}"`) + } + + /* 2 — a country, with no pin */ + const b = await read(page, '?country=IND') + if (!b.mounted) fails.push('?country: the leaf did not mount') + else if (!/India/i.test(b.text)) fails.push('?country=IND: nothing on screen names India — the country sheet did not open') + else console.log(' ok ?country=IND'.padEnd(34) + 'the country sheet opened') + + /* 3 — a country nobody has. The sheet must NOT open, and the map must still be a map. */ + const c = await read(page, '?country=ZZZ') + if (!c.mounted) fails.push('?country=ZZZ: the leaf did not mount — a bad parameter must never break the embed') + else console.log(' ok ?country=ZZZ'.padEnd(34) + 'unknown country ignored, leaf still renders') + + /* 4 — the view that already existed, kept honest */ + const d = await read(page, '?view=zero') + if (!d.mounted) fails.push('?view=zero: the leaf did not mount') + else if (!d.text.length) fails.push('?view=zero: the leaf rendered nothing') + else console.log(' ok ?view=zero'.padEnd(34) + 'renders') + + if (POISON) { + if (!fails.length) { console.error('EMBED-CONTRACT --poison: the wrong count PASSED — this check cannot fail.'); process.exit(1) } + console.log(`EMBED-CONTRACT --poison: caught it — ${fails[0]}`) + console.log('EMBED-CONTRACT: SELF-TEST PASS — the check fails as designed.') + process.exit(0) + } + if (fails.length) { + console.error(`EMBED-CONTRACT: FAIL — ${fails.length} promise(s) this map's URL makes are broken:`) + for (const f of fails) console.error(` ${f}`) + process.exit(1) + } + console.log(`EMBED-CONTRACT: PASS — ${matched} of ${total} pins for a three-code link, a country opens from its ISO3 alone, an unknown one is ignored, and ?view=zero still renders.`) +} finally { + try { await browser?.close() } catch (_) {} + try { srv?.close?.() } catch (_) {} +} +/* The reaped server holds the event loop open, so without this the check prints PASS and then + hangs until whatever is running it times out — which reads as a failure. */ +process.exit(0) diff --git a/package.json b/package.json index 9b419723..c28aa596 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,8 @@ "check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre", "check:gospel-all": "node internal/check-gospel-all.mjs", "check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs", + "check:embed-contract": "node internal/check-gospel-embed-contract.mjs", + "check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison", "check:map-attribution": "node internal/check-map-attribution.mjs", "check:studio-rail": "node internal/check-studio-rail.mjs", "check:translator-contacts": "node internal/check-translator-contacts.mjs", @@ -367,6 +369,7 @@ "check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs", "check:studio-rail": "the Plugin Studio rail is laid out like plugin-designs/: one folder per bundle with its elements inside, bundles in the section of their highest capability (5 \u2192 1) and in name order, one canonical node per plugin. Runs the studio's real rail code on the built manifest, headless.", "check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.", + "check:embed-contract": "the map's URL is a PUBLIC INTERFACE: a partner embeds ?codes=, ?country= or ?view=zero in an iframe and never touches it again. A refactor breaks those links SILENTLY \u2014 the leaf still mounts, renders and logs no errors, it just shows the whole world to a church that asked for its forty groups. Asserts through the reader's eyes (the partner-list notice's two numbers), with both numbers and the probe codes derived from the roster the leaf itself reads, never typed in. Browser gate; needs no server of its own. Self-test: `bun run check:embed-contract:poison`.", "check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.", "check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the agent's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.", "check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.", diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue index 24b4c28b..b261bdd3 100644 --- a/plugin-designs/cities-internet/connectivity-map.vue +++ b/plugin-designs/cities-internet/connectivity-map.vue @@ -362,9 +362,22 @@ let unmounted = false // set on unmount; onMounted's awaits check it before bu onMounted(async () => { try { await ensureEngineScript(ciProvider) } catch (_) { /* fall through to the notice below */ } - const gl = engineGlobal(ciProvider) || window.mapboxgl + // THE ENGINE THE PAGE ASKED FOR, OR NONE. This read `engineGlobal(ciProvider) || window.mapboxgl`: + // engineGlobal already answers for Mapbox, so the fallback only fired when MapLibre was asked for and + // failed to load, and it silently handed back MAPBOX, the token-gated engine, on a page that chose + // the keyless one (regions' C3 read; upg100's ade12e4c is the same fix). A missing engine is now a + // visible error that names it, never a quiet swap. + const gl = engineGlobal(ciProvider) if (unmounted) return - if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return } + if (!gl) { + const engine = ciProvider === 'maplibre' ? 'MapLibre' : 'Mapbox' + console.warn(`[connectivity-map] the ${engine} engine did not load; refusing to substitute another engine.`) + // messages.engineNotLoadedDetail arrives with upg100's ade12e4c; until it does, English is the fallback. + const msg = t('messages.engineNotLoadedDetail', { engine }) + err.value = msg && msg !== 'messages.engineNotLoadedDetail' ? msg : `The ${engine} map engine did not load. The list and legend are unaffected.` + return + } + if (!mapEl.value) { err.value = 'map engine not loaded'; return } // RTL SHAPING, before the first map exists. This raw map does not go through useMapInstance // (which installs it), and the basemap draws each place in its OWN script: over the Middle East // most labels are Arabic, Persian or Hebrew for EVERY viewer. An entry-time call cannot do this, diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index c1fac367..97085bad 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -2175,8 +2175,8 @@ onBeforeUnmount(() => {
-
Research map loading…
-
The map could not be loaded. {{ mapError }}
+
{{ t('messages.loadingNamed', { name: t('tabs.map') }) }}
+
{{ t('messages.couldNotLoadNamed', { name: t('tabs.map') }) }} {{ mapError }}
diff --git a/plugin-designs/country-posters/country-posters-mfe.vue b/plugin-designs/country-posters/country-posters-mfe.vue index e8aa8928..133df504 100644 --- a/plugin-designs/country-posters/country-posters-mfe.vue +++ b/plugin-designs/country-posters/country-posters-mfe.vue @@ -104,7 +104,13 @@ let offScroll = null // partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads // provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here // before it: the inner map cannot honour a key the shell never passed down. -const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list'] +// ★ 'dataSource' and 'tokenUrl' added 2026-09-26 (the same gap regions fixed in f0aa5ab3). +// dataSource is LIVE: countries-map reads it from its own profile-config (inject('dataSource'), +// countries-map.vue:392/622), nothing pinned it in spec.config or pane.config, and nothing publishes it +// page-wide, so a partner's dataSource on was dropped and the map fell back to +// sources.json's activeSource. tokenUrl is LATENT: the outer ProfileLoader fetches it and publishes +// the token page-wide, so the panes get a token today, but not the URL to re-ask it themselves. +const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'tokenUrl', 'dataSource', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list'] function paneElement(pane) { const el = document.createElement(TAG) diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue index a6e2c3e0..f667b2cc 100644 --- a/plugin-designs/gospel-resources/resources-map.vue +++ b/plugin-designs/gospel-resources/resources-map.vue @@ -303,6 +303,48 @@ const canHear = (c) => c.sg !== 1 && (!!c.ga || !!(c.au && /audio/.test(c.au.m || '')) || !!c.f || !!c.bv) +/** + * ═══ A PARTNER'S OWN LIST, AS AN ADDRESS ══════════════════════════════════════════════════ + * + * This map ships Doxa's roster, and the whole point of a public corpus is that OTHER lists can + * stand on it: a sending church with forty groups, a network with its own hundred. Until now a + * partner could embed this map and then had to tell their reader to go hunting for their own + * people in 12,379 dots. + * + * ?codes=ckb,bal,pbu the map draws only those languages, framed on them + * ?country=IND opens India's sheet, no pin needed + * ?view=zero the groups with nothing (already here) + * + * The join is ISO 639-3, the same key everything else here joins on, so a partner's list needs + * no agreement with ours — only the codes their own data already carries. + * + * ⚠ IT FILTERS, IT NEVER INVENTS. A code nobody here has draws nothing; the counter says so, + * and the reader gets a way back to the whole map. A link that matched none of its codes shows + * an empty map reading "0 / 12,379", which is the truth about that link — showing the full map + * instead would tell a partner their broken list works. + */ +const linkCodes = (() => { + try { + const raw = new URLSearchParams(window.location.search).get('codes') || '' + return new Set(raw.split(',').map((c) => c.trim().toLowerCase()).filter((c) => /^[a-z]{3}$/.test(c))) + } catch { return new Set() } +})() +const linkActive = ref(linkCodes.size > 0) +const linkMatched = computed(() => (linkActive.value + ? pinRows.value.reduce((n, r) => n + (linkCodes.has(r[4]) ? 1 : 0), 0) + : 0)) + +/* THE WAY BACK. A reader who arrives on a partner's embed is looking at somebody else's forty + groups and has no way to learn that the map holds 12,379 — so the counter is a button, and it + drops the clause rather than rebuilding the source: `lc` stays baked and simply stops being + asked about. */ +function clearLinkList() { + if (!linkActive.value) return + linkActive.value = false + setPinFilter(pinFilter.value) + if (mapRef.value) fitToPins(mapRef.value, null) +} + const pinFilter = ref('all') // all | none | some | both | hnr function setPinFilter(v) { pinFilter.value = v @@ -313,12 +355,16 @@ function setPinFilter(v) { /* ONE expression, applied to both layers. Written twice, the halo and the pin would drift and a filtered-out pin would leave its halo behind as a ghost dot the reader cannot tell from a real one. */ - const expr = + const base = v === 'all' ? null : v === 'none' ? ['==', ['get', 'tier'], 0] : v === 'some' ? ['==', ['get', 'tier'], 1] : v === 'hnr' ? ['==', ['get', 'hnr'], true] : ['==', ['get', 'tier'], 2] + /* The partner's list ANDs with whatever the reader has chosen, rather than replacing it: a + church embedding their forty groups still gets the gap filter to work inside their forty. */ + const link = linkActive.value ? ['==', ['get', 'lc'], true] : null + const expr = base && link ? ['all', link, base] : (base || link) if (m.getLayer('gr-pins-halo')) m.setFilter('gr-pins-halo', expr) m.setFilter('gr-pins', expr) } @@ -366,8 +412,23 @@ function openFromUrl() { const view = (sp.get('view') || '').trim().toLowerCase() if (view === 'zero' || view === 'gap') { setPinFilter('none'); openGapList() } const code = (sp.get('code') || '').trim() - if (!code) return const country = (sp.get('country') || '').trim() + /* ⚠ A COUNTRY IS AN ADDRESS TOO, and it was not one: ?country=IND did nothing at all unless a + ?code came with it, because country was only ever a tie-breaker between two pins sharing a + language. The brief's second requirement — click a country, see its resources — had no URL, + which is precisely what a partner embeds. Accepts the ISO3 or the country's own name. */ + if (!code && country) { + const key = country.toUpperCase() + const iso = byIso.value[key] ? key + : Object.keys(byIso.value).find((k) => String(byIso.value[k]?.country || '').toLowerCase() === country.toLowerCase()) + if (iso) { + openCountry(iso) + const map0 = mapRef.value + if (map0) fitToPins(map0, (r) => r[3] === iso) + } + return + } + if (!code) return const name = (sp.get('name') || '').trim() let best = -1 for (let i = 0; i < pinRows.value.length; i++) { @@ -1258,7 +1319,7 @@ function pinFC() { type: 'Feature', // hnr is baked so the filter stays a GL-side predicate like the others: recomputing it // per toggle would re-upload 12,379 features to answer a yes/no already known here. - properties: { i, tier: p[6], color: PIN_COLOR[p[6]], rim: PIN_RIM[p[6]], hnr: hnrFor(p[4]) }, + properties: { i, tier: p[6], color: PIN_COLOR[p[6]], rim: PIN_RIM[p[6]], hnr: hnrFor(p[4]), lc: linkCodes.has(p[4]) }, geometry: { type: 'Point', coordinates: [p[2], p[1]] }, } } @@ -1270,8 +1331,13 @@ function pinFC() { * every Doxa group is on screen at the start, which is the one moment the whole roster is * the subject. Padding keeps the legend card from covering the westernmost pins. */ -function fitToPins(map) { - const rows = pinRows.value +function fitToPins(map, keep) { + const all = pinRows.value + /* FRAMED ON WHAT IS DRAWN. With a partner's list on the URL the whole-world frame is the wrong + first sight: their forty groups become forty specks. Falls back to everything when the + subset is empty, because a fit to nothing is a fit to the null island. */ + const sub = keep ? all.filter(keep) : all + const rows = sub.length ? sub : all if (!map || !rows.length) return let w = 180, e = -180, s = 90, n = -90 for (const r of rows) { @@ -1284,7 +1350,7 @@ function fitToPins(map) { } if (w > e || s > n) return try { - map.fitBounds([[w, s], [e, n]], { padding: { top: 24, right: 24, bottom: 24, left: 24 }, duration: 0, maxZoom: 4 }) + map.fitBounds([[w, s], [e, n]], { padding: { top: 24, right: 24, bottom: 24, left: 24 }, duration: 0, maxZoom: keep ? 6 : 4 }) } catch (_) { /* a bad extent must never stop the map drawing */ } } @@ -1515,7 +1581,15 @@ onMounted(async () => { * happened here, and the two engines do resolve their styles on different schedules, so the * guard is cheap insurance rather than a repair. */ - const draw = () => { paint(map, adm0); paintPins(map); fitToPins(map); openFromUrl() } + const draw = () => { + paint(map, adm0) + paintPins(map) + /* The link clause lives in setPinFilter, and nothing has called it yet at first paint — + so without this the partner's list would be baked into the features and ignored. */ + if (linkActive.value) setPinFilter(pinFilter.value) + fitToPins(map, linkActive.value ? (r) => linkCodes.has(r[4]) : null) + openFromUrl() + } if (typeof map.isStyleLoaded === 'function' && map.isStyleLoaded()) draw() else map.once('load', draw) } catch (e) { err.value = String(e.message || e) } @@ -1674,6 +1748,13 @@ useShadowStyles( Capped to the map's own height and scrolled inside. The cap is the CONTAINER's height, not the viewport's, because this leaf is embedded and the two are not the same thing — using vh here would break on every partner page that gives the map a fixed box. */ + '.gm-linklist{display:flex;align-items:center;gap:8px;width:100%;margin:0 0 8px;padding:7px 10px;' + + 'border:1px solid #d1d4cd;border-radius:8px;background:#f3f3f1;color:#3b463d;font:inherit;font-size:13px;' + + 'cursor:pointer;text-align:start}' + + '.gm-linklist:hover,.gm-linklist:focus-visible{background:#e8eae4}' + + '.gm-linklist span{color:#6d766e}' + + '.is-dark .gm-linklist{background:rgba(59,70,61,.6);border-color:#6d766e;color:#f3f3f1}' + + '.is-dark .gm-linklist span{color:#d1d4cd}' + '.gm-legend{position:absolute;top:8px;left:8px;width:var(--map-legend-width,380px);' + 'max-height:calc(100% - 16px);overflow-y:auto;overscroll-behavior:contain;' + 'max-width:calc(100% - 16px);box-sizing:border-box;z-index:4;padding:8px 10px;' + @@ -1881,6 +1962,15 @@ useShadowStyles(
+ +