Merge origin/development into the handoff integration

This commit is contained in:
Doxa Maps 2026-09-29 14:50:47 +00:00
commit 4d65d399b1
11 changed files with 323 additions and 43 deletions

View file

@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* check-control-inset-measured — bottomControlInset actually MEASURES the map's bottom controls.
*
* ★ WHY THIS EXISTS SEPARATELY FROM check:popup-pan-controls. That gate proves the ARITHMETIC: given
* a band height, the pan clears it. This one proves the band height is real. The two halves fail
* differently, and the second failure is the silent one: if this function returns 0 — a selector
* typo, an engine that renames its control classes, a control that lives outside the container —
* then panPlanFor is handed bottomInset 0, behaves exactly as it did before the fix, and
* check:popup-pan-controls still passes every case, because its arithmetic is still correct.
* The bug would be back and both gates would be green.
*
* So this asserts the measurement against a REAL DOM: the engine's own class names, the box model it
* actually produces, and the cases that make a naive implementation wrong.
*
* ★ IT RUNS THE SHIPPED FUNCTION, not a copy. bottomControlInset is not exported — it lives inside
* the SFC — so the source is read and evaluated in the page, the same reach
* check:popup-pan-controls uses for panPlanFor. A re-typed copy would pass forever while the
* component drifted, which is the one failure this gate must not have.
*
* Exit 0 pass · 1 fail · 2 the function could not be found · 75 no browser slot.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from './lib/playwright-guarded.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'library/components/maps/RegionCountryMap.vue')
const src = readFileSync(SRC, 'utf8')
const m = src.match(/function bottomControlInset\(container\)[\s\S]*?\n\}/)
if (!m) {
console.error('CONTROL-INSET: NOT MEASURED — bottomControlInset was not found in RegionCountryMap.vue.')
console.error(' Renamed or moved? Re-point this gate rather than dropping it: without it, an inset that')
console.error(' silently returns 0 restores the original bug while every other gate stays green.')
process.exit(2)
}
const FN = m[0]
// A map container shaped like the real one: the engine's four corner containers, its attribution in
// the bottom-right, and our own pin hint. Sizes are CSS, so the browser computes the boxes.
const PAGE = (inner) => `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{margin:0}
.map{position:relative;width:366px;height:732px;overflow:hidden}
.maplibregl-ctrl-bottom-left{position:absolute;bottom:0;left:0;pointer-events:none}
.maplibregl-ctrl-bottom-right{position:absolute;bottom:0;right:0;pointer-events:none}
.maplibregl-ctrl{pointer-events:auto;margin:0}
.maplibregl-ctrl-attrib{background:rgba(255,255,255,.5);padding:0 5px}
.pin-hint-pill{position:absolute;bottom:8px;left:8px;height:24px;padding:0 10px}
</style></head><body><div class="map">${inner}</div></body></html>`
const ATTRIB = (text = '&copy; OpenStreetMap contributors') =>
`<div class="maplibregl-ctrl-bottom-right"><details class="maplibregl-ctrl maplibregl-ctrl-attrib" open>
<summary>i</summary><div class="maplibregl-ctrl-attrib-inner">${text}</div></details></div>`
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true })
let bad = 0
const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) }
/** Evaluate the SHIPPED function against a page, and also report what the DOM says, independently. */
async function measure(inner) {
await page.setContent(PAGE(inner))
return page.evaluate(([fnSrc]) => {
const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)()
const container = document.querySelector('.map')
const box = container.getBoundingClientRect()
// The independent answer: the highest top of anything that takes a pointer event down there.
let top = Infinity
for (const el of container.querySelectorAll('.maplibregl-ctrl, .pin-hint-pill')) {
const r = el.getBoundingClientRect()
if (r.width > 0 && r.height > 0 && r.top < top) top = r.top
}
const expected = Number.isFinite(top) ? Math.max(0, Math.round(box.bottom - top)) : 0
return { got: fn(container), expected }
}, [FN])
}
// ── 1. THE ORDINARY CASE: one line of attribution. The inset must be positive and must match what
// the DOM independently says — not merely "some number", which a stub returning 1 would satisfy.
{
const { got, expected } = await measure(ATTRIB())
ok(got > 0, `a visible attribution reserves a band (${got}px), not 0 — the silent-failure case`)
ok(got === expected, `and it equals what the DOM says (${got} vs ${expected})`)
}
// ── 2. NOTHING TO RESERVE. No controls at all must give exactly 0, so a map without chrome is not
// penalised and the pan behaves as it did before this rule existed.
{
const { got } = await measure('')
ok(got === 0, `no bottom controls reserves exactly 0 (${got})`)
}
// ── 3. A COLLAPSED CONTROL RESERVES NOTHING. An element present but zero-sized takes no click, and
// counting it would reserve the whole map height — the pan would then refuse every popup.
{
const { got } = await measure('<div class="maplibregl-ctrl-bottom-right"><div class="maplibregl-ctrl" style="height:0;width:0"></div></div>')
ok(got === 0, `a zero-sized control reserves nothing (${got})`)
}
// ── 4. THE TALLEST WINS, ACROSS CORNERS. Our pin hint sits bottom-LEFT while the attribution is
// bottom-RIGHT; the band has to cover whichever reaches higher, or the taller one still takes the
// click. Measured with a deliberately tall hint.
{
const inner = ATTRIB() + '<div class="pin-hint-pill" style="height:80px"></div>'
const { got, expected } = await measure(inner)
ok(got === expected && got >= 80, `the taller of two corners wins (${got}px covers an 80px hint)`)
}
// ── 5. A WRAPPED ATTRIBUTION IS TALLER, AND THE INSET FOLLOWS IT. This is the case a hard-coded
// constant gets wrong: at 390 a long credit wraps to two lines and the band grows.
{
const oneLine = await measure(ATTRIB())
const wrapped = await measure(ATTRIB('&copy; OpenStreetMap contributors, &copy; OpenMapTiles, &copy; Natural Earth, and a long credit that wraps at this width'))
ok(wrapped.got > oneLine.got, `a wrapped credit reserves more than one line (${wrapped.got} > ${oneLine.got})`)
ok(wrapped.got === wrapped.expected, `and still equals the DOM (${wrapped.got} vs ${wrapped.expected})`)
}
// ── 6. A CONTROL OUTSIDE THE CONTAINER IS NOT OURS TO RESERVE. querySelectorAll is scoped to the
// container; a stray control elsewhere on the page must not inflate the band.
{
await page.setContent(PAGE('') .replace('</body>', `${ATTRIB()}</body>`))
const got = await page.evaluate(([fnSrc]) => {
const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)()
return fn(document.querySelector('.map'))
}, [FN])
ok(got === 0, `a control outside the map container is ignored (${got})`)
}
// ── 7b. THE MAPBOX ENGINE'S CORNERS COUNT TOO. The component takes a provider prop and swaps
// engines, and the selector names BOTH prefixes — but every case above renders maplibre class names,
// so the `.mapboxgl-ctrl-bottom-*` half was never exercised. A typo there would be invisible: the map
// would draw, the pan would run, and on Mapbox the popup's buttons would sit under the attribution
// exactly as they did before the fix. Same page shape, mapbox class names.
{
const mb = `<div class="mapboxgl-ctrl-bottom-right"><div class="mapboxgl-ctrl mapboxgl-ctrl-attrib" style="height:40px">
<div class="mapboxgl-ctrl-attrib-inner">&copy; Mapbox</div></div></div>`
await page.setContent(PAGE(mb))
const r = await page.evaluate(([fnSrc]) => {
const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)()
const container = document.querySelector('.map')
const box = container.getBoundingClientRect()
let top = Infinity
for (const el of container.querySelectorAll('.mapboxgl-ctrl, .pin-hint-pill')) {
const q = el.getBoundingClientRect()
if (q.width > 0 && q.height > 0 && q.top < top) top = q.top
}
return { got: fn(container), expected: Number.isFinite(top) ? Math.max(0, Math.round(box.bottom - top)) : 0 }
}, [FN])
ok(r.got > 0, `a Mapbox attribution reserves a band (${r.got}px), not 0 — the provider swap is covered`)
ok(r.got === r.expected, `and it equals what the DOM says (${r.got} vs ${r.expected})`)
}
// ── 7. NO CONTAINER AT ALL. A map mid-teardown must plan no pan rather than throw.
{
const got = await page.evaluate(([fnSrc]) => {
const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)()
return fn(null)
}, [FN])
ok(got === 0, `a null container returns 0 rather than throwing (${got})`)
}
await browser.close()
if (bad) {
console.error(`\nCONTROL-INSET: FAIL — ${bad} case(s). The band the pan reserves is not the band the`)
console.error('controls occupy, so a popup can be panned "clear" and still have its buttons covered.')
process.exit(1)
}
console.log('\nCONTROL-INSET: PASS — the inset measures the real control band, in both engines\' corners.')

View file

@ -30,11 +30,14 @@
* WHAT IT ASSERTS: every internal/*.mjs whose name or header marks it as a gate (check-*, verify-*,
* smoke-*, or a header containing "GATE") is either reachable or explicitly tagged @one-shot; and every
* check:/verify:/smoke: SCRIPT is run by a standing chain (by exact name), declared on demand with a
* reason, or in the shrink-only baseline of 2026-09-29's orphans (the second arm, below).
* reason, or in the shrink-only baseline of 2026-09-29's orphans (the second arm, below); and every
* gate-shaped FILE under plugin-designs/ or library/ is run by path from a script or another file's code
* (comments stripped), tagged @one-shot, or in that baseline's `files` list.
*
* node internal/check-no-orphan-gates.mjs check (the chain arm's self-test runs first, every time)
* node internal/check-no-orphan-gates.mjs --shrink drop baselined scripts since chained, declared or deleted
* node internal/check-no-orphan-gates.mjs --init write the orphan baseline, only if none exists
* node internal/check-no-orphan-gates.mjs --init-files add the `files` list, only if the baseline has none
*
* NEGATIVE-TESTED: removing the check:shell-scroll entry from package.json makes this fail by name.
*/
@ -113,6 +116,74 @@ if (orphans.length) {
process.exit(1)
}
// ── THE SAME QUESTION FOR GATE FILES OUTSIDE internal/ (2026-09-29) ─────────────────────────
// The walk above reads internal/ only, so a plugin's own gate that nobody registered was invisible:
// on 2026-09-29 six plugin-local check-*.mjs files were referenced by nothing. Every TRACKED file under
// plugin-designs/ or library/ named check-/verify-/smoke-*.mjs must be run by PATH from a package.json
// script, or referenced from another file's CODE, or carry @one-shot, or sit in the shrink-only baseline
// (orphan-checks-baseline.json `files`) for its owner.
// ⚠ PATHS, AND CODE ONLY. A basename counts only where no other gate file shares it:
// library/scripts/research/verify-map-paint.mjs shares its name with internal/verify-map-paint.mjs, which
// is what verify:map-paint runs, so a name match let one registration vouch for a different file. And
// comments are stripped first: a file mentioned in prose is not run by it (the lesson recorded above).
export function outerOrphans({ gateFiles, scriptCmds, codeByFile, oneShot = new Set(), baseline = [] }) {
const base = (p) => p.slice(p.lastIndexOf('/') + 1)
const count = new Map()
for (const p of gateFiles) count.set(base(p), (count.get(base(p)) || 0) + 1)
const out = []
for (const p of gateFiles) {
if (!/^(plugin-designs|library)\//.test(p) || oneShot.has(p)) continue
const unique = count.get(base(p)) === 1
const hit = (text) => text.includes(p) || (unique && text.includes(base(p)))
if (hit(scriptCmds)) continue
if ([...codeByFile].some(([f, code]) => f !== p && hit(code))) continue
out.push(p)
}
const bl = new Set(baseline)
return { unreached: out, fresh: out.filter((p) => !bl.has(p)), gone: [...bl].filter((p) => !out.includes(p)) }
}
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1')
{
// Self-test on planted cases: each rule must be able to fail.
const files = ['plugin-designs/a/scripts/check-x.mjs', 'plugin-designs/b/scripts/check-y.mjs', 'internal/verify-z.mjs', 'library/s/verify-z.mjs']
const run = (scripts, code) => outerOrphans({ gateFiles: files, scriptCmds: scripts, codeByFile: new Map(code) }).fresh
const allWired = 'node plugin-designs/a/scripts/check-x.mjs && node plugin-designs/b/scripts/check-y.mjs && node library/s/verify-z.mjs'
const cases = [
['everything registered by path passes', run(allWired, []), 0],
['a plugin gate nobody runs fails', run('node plugin-designs/a/scripts/check-x.mjs && node library/s/verify-z.mjs', []), 1],
['a gate run from another file\'s code passes', run('node plugin-designs/a/scripts/check-x.mjs && node library/s/verify-z.mjs', [['internal/all.mjs', "spawn('node', ['plugin-designs/b/scripts/check-y.mjs'])"]]), 0],
['a gate only MENTIONED in a comment fails', run('node plugin-designs/a/scripts/check-x.mjs && node library/s/verify-z.mjs', [['internal/all.mjs', stripComments('// see check-y.mjs')]]), 1],
['a shared basename cannot vouch for the other file', run('node plugin-designs/a/scripts/check-x.mjs && node plugin-designs/b/scripts/check-y.mjs && node internal/verify-z.mjs', []), 1],
]
const bad = cases.filter(([, got, want]) => (want === 0) !== (got.length === 0))
if (bad.length) { for (const [n, got] of bad) console.error(` ✗ self-test: ${n} -> ${JSON.stringify(got)}`); console.error('NO-ORPHAN-GATES: SELF-TEST FAILED — the file arm cannot be trusted'); process.exit(1) }
}
{
const gateFiles = execSync('git ls-files -- internal plugin-designs library', { cwd: ROOT, encoding: 'utf8' }).split('\n')
.filter((p) => /\/(check|verify|smoke)-[^/]*\.mjs$/.test(p) && !p.includes('/data/'))
const scriptCmds = Object.values(JSON.parse(pkgRaw).scripts || {}).join('\n')
const codeFiles = execSync('git ls-files -- "*.mjs" "*.js" "*.vue"', { cwd: ROOT, encoding: 'utf8' }).split('\n').filter((p) => p && !p.includes('node_modules'))
const codeByFile = new Map(codeFiles.map((p) => { try { return [p, stripComments(readFileSync(join(ROOT, p), 'utf8'))] } catch { return [p, ''] } }))
const oneShot = new Set(gateFiles.filter((p) => { try { return /^\s*(?:\*|\/\/)\s*@one-shot\b/m.test(readFileSync(join(ROOT, p), 'utf8').slice(0, 2600)) } catch { return false } }))
const ORPHANS_REL_F = 'internal/fixtures/orphan-checks-baseline.json'
const bl = (() => { try { return JSON.parse(readFileSync(join(ROOT, ORPHANS_REL_F), 'utf8')) } catch { return null } })()
const v = outerOrphans({ gateFiles, scriptCmds, codeByFile, oneShot, baseline: bl?.files || [] })
if (process.argv.includes('--init-files') || process.argv.includes('--shrink')) {
if (bl && (process.argv.includes('--shrink') || !bl.files)) {
const files = process.argv.includes('--init-files') && !bl.files ? v.unreached : (bl.files || []).filter((p) => !v.gone.includes(p))
writeFileSync(join(ROOT, ORPHANS_REL_F), JSON.stringify({ ...bl, files: files.sort() }, null, 1) + '\n')
console.log(`NO-ORPHAN-GATES: file baseline ${process.argv.includes('--init-files') ? 'written' : 'shrunk'}: ${files.length} file(s)`)
}
}
console.log(` gate files : ${gateFiles.length} tracked; outside internal/, ${v.unreached.length} run by nothing (${(bl?.files || []).length - v.gone.length} baselined for their owners)`)
if (v.fresh.length && !process.argv.includes('--init-files')) {
console.error(`NO-ORPHAN-GATES: FAIL — ${v.fresh.length} gate file(s) outside internal/ that nothing runs:`)
for (const p of v.fresh) console.error(` ✗ ${p}`)
console.error(' Register it by PATH in a package.json script (and chain it), or tag @one-shot if it was a one-bug diagnostic.')
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 —
@ -215,7 +286,7 @@ if (process.argv.includes('--init') || process.argv.includes('--shrink')) {
const init = process.argv.includes('--init')
if (init && orphanBase) { console.error(`NO-ORPHAN-GATES: --init refused, ${ORPHANS_REL} exists (use --shrink)`); process.exit(1) }
const keep = init ? v.unreached.filter((k) => !(k in onDemand)) : orphanBase.unreached.filter((k) => !v.gone.includes(k))
writeFileSync(join(ROOT, ORPHANS_REL), JSON.stringify({ about: 'check/verify/smoke scripts that no standing chain ran on 2026-09-29 and that nobody had declared on-demand. Each owner chains it, declares it in on-demand-checks.json with a reason, or deletes it. This list only shrinks (--shrink).', unreached: keep.sort() }, null, 1) + '\n')
writeFileSync(join(ROOT, ORPHANS_REL), JSON.stringify({ ...(orphanBase || {}), about: 'check/verify/smoke scripts that no standing chain ran on 2026-09-29 and that nobody had declared on-demand. Each owner chains it, declares it in on-demand-checks.json with a reason, or deletes it. This list only shrinks (--shrink).', unreached: keep.sort() }, null, 1) + '\n')
console.log(`NO-ORPHAN-GATES: orphan baseline ${init ? 'written' : 'shrunk'}: ${keep.length} script(s)`)
process.exit(0)
}

View file

@ -49,13 +49,25 @@ const argv = process.argv.slice(2)
const ONLY = argv.includes('--only') ? argv[argv.indexOf('--only') + 1].split(',') : null
const REPORT = argv.includes('--report')
// The 7 production maps' bundles (PRODUCTION-MATRIX.md) and the Experimental shelf.
const BUNDLES = [
'dashboard-plus-grid', 'research-dashboard', 'research-map-plus-grid', 'doxa-research-map', 'people-groups-grid',
'regions-dashboard', 'my-upg-100-list', 'doxa-simple-map', 'cities-internet', 'gospel-resources',
'all-people-groups', 'country-posters', 'doxa-vision-card', 'pplr-data-maps', 'prayer-walk-planner',
'upg-neighbor', 'wall-of-people-groups', 'key-cities', 'gospel-internet',
]
// ★ THE POPULATION IS DERIVED, NOT TYPED (2026-09-29). It used to be a hand list of 19 names, and it
// drifted: the PRODUCTION country-map was never on it, so the LIST-FETCH it carried passed unseen, and
// big-picture fetched the list correctly with nothing to catch a regression. Now every plugin-designs/<b>/
// with an index.js is measured unless EXCLUDED names it with a reason, so a new bundle is covered the day
// it lands, and leaving one out is a decision someone wrote down.
const EXCLUDED = {}
const BUNDLES = readdirSync(resolve(ROOT, 'plugin-designs'))
.filter((b) => existsSync(resolve(ROOT, 'plugin-designs', b, 'index.js')) && !(b in EXCLUDED)).sort()
// Fail loud, never narrow silently: an --only name that is not a measured bundle measured NOTHING and
// used to print "PASS: 0 bundles"; an exclusion naming no bundle is a stale decision.
{
const unknown = (ONLY || []).filter((x) => !BUNDLES.includes(x))
const stale = Object.keys(EXCLUDED).filter((b) => !existsSync(resolve(ROOT, 'plugin-designs', b, 'index.js')))
if (unknown.length || stale.length) {
if (unknown.length) console.error(`UNIVERSAL-PROPS: --only names no measured bundle: ${unknown.join(', ')} (measured: ${BUNDLES.join(', ')})`)
if (stale.length) console.error(`UNIVERSAL-PROPS: EXCLUDED names no bundle: ${stale.join(', ')} (a stale exclusion)`)
process.exit(2)
}
}
const ALLOW = {
'*:LOC-ENUS:library/utils/formatCount.js':
'HELD DECISION, and this file IS the decision: formatCount() is the one place the count locale is '
@ -68,6 +80,9 @@ const ALLOW = {
+ 'can be wildcarded, and a new toLocaleString(\'en-US\') inside research-dashboard or '
+ 'people-groups-grid still warns. Revisit only if the numerals decision changes.',
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',
'demo-map:LIST-DSM': 'scaffolding: demo-map demonstrates the raw DataSourceManager path itself, and it is off the '
+ 'production and Experimental shelves (the gallery\'s NOT_EXPERIMENTAL, the 2026-09-24 roundup ruling). '
+ 'Its other props are still measured. Revisit if it is ever shelved for readers.',
'*:LOC-ENUS:library/components/maps/RegionCountryMap.vue':
'HELD DECISION, not an oversight: the map popup formats population as fixed en-US on purpose. '
+ 'Their own site renders en-US numerals, which is the evidence on the card '
@ -258,8 +273,7 @@ const filesWithLibrary = (dir) => { const own = files(dir); return [...own, ...l
const findings = []
const matrix = {}
for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
const dir = resolve(ROOT, 'plugin-designs', b)
if (!existsSync(dir)) continue
const dir = resolve(ROOT, 'plugin-designs', b) // derived from the tree above, so it exists
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok', list: 'ok' }
for (const f of filesWithLibrary(dir)) {
const src = stripComments(readFileSync(f, 'utf8'))

View file

@ -105,5 +105,11 @@
"verify:interop",
"verify:interop:self-test",
"verify:interop:self-test:camera-freeze"
],
"files": [
"library/scripts/research/verify-map-paint.mjs",
"plugin-designs/doxa-research-map/scripts/check-reduced-motion-jumps.mjs",
"plugin-designs/gospel-resources/scripts/verify-translator-org-links.mjs",
"plugin-designs/research-map-plus-grid/scripts/check-ui-strings-have-keys.mjs"
]
}

View file

@ -106,6 +106,7 @@ const GATES = [
// cases would need the defect rebuilt into a bundle); the arm that can is check:no-silent-engine's
// --self-test, a source gate in gates:post.
{ script: 'check:engine-contract', why: "gospel's resources map uses the engine it was asked for, or fails visibly" },
{ script: 'check:control-inset', why: 'the band the popup pan reserves is the band the map controls really occupy, on both engines' },
]
// ── PRECONDITION 1: a built tree ────────────────────────────────────────────

View file

@ -236,6 +236,7 @@
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs",
"check:popup-pan-controls": "node internal/check-popup-pan-clears-controls.mjs",
"check:control-inset": "node internal/check-control-inset-measured.mjs",
"check:viewport-meta": "node internal/check-viewport-meta.mjs",
"check:viewport-meta:self-test": "VIEWPORT_META_SELFTEST=1 node internal/check-viewport-meta.mjs",
"check:poster-attribution": "node internal/check-poster-attribution.mjs",
@ -400,7 +401,7 @@
"check:approx-pin-note": "people-group pins say they are localities, not towns or boundaries, in ONE shared wording (library/components/legends/ApproxPinNote.vue): the colocation index finds groups sharing a coordinate, the note names them only for a shared point, never prints a raw coordinate, and renders from each locale's catalogue. Headless.",
"check:auto-engine": "BROWSER. The default engine (auto) is MapLibre, token or not: on one map leaf, no token AND a host token alone both draw with MapLibre, paint, and make zero api.mapbox.com requests; ?provider=mapbox with that token draws Mapbox and carries it. In gates:post, NOT MEASURED outside the browser slot.",
"check:no-new-driver-references": "shipped text (library, plugins, internal, docs, skills, README) neither quotes nor cites the Driver: a line that refers to the Driver and is not in internal/fixtures/driver-references-baseline.json FAILS. Forward-only: the baseline, keyed by file and line text with counts (not line numbers), may only shrink as owners sweep their areas (--shrink tidies it; it never grows). Matches the role word only, not a GPU or device driver. Node only.",
"check:no-orphan-gates": "every gate-shaped internal/*.mjs is reachable or tagged @one-shot; and every check:/verify:/smoke: script is run by a standing chain, matched by EXACT name (gates:pre, gates:post, the browser suite GATES, the build, and what those bun run), or declared in internal/fixtures/on-demand-checks.json with its reason, or in the shrink-only baseline of the 2026-09-29 orphans. A chained :self-test does not chain its gate. --shrink / --init maintain the baseline.",
"check:no-orphan-gates": "every gate-shaped internal/*.mjs is reachable or tagged @one-shot; and every check:/verify:/smoke: script is run by a standing chain, matched by EXACT name (gates:pre, gates:post, the browser suite GATES, the build, and what those bun run), or declared in internal/fixtures/on-demand-checks.json with its reason, or in the shrink-only baseline of the 2026-09-29 orphans. A chained :self-test does not chain its gate. --shrink / --init maintain the baseline. And every gate-shaped FILE under plugin-designs/ or library/ is run by PATH from a script or another file's code (comments stripped; a basename counts only when no other gate file shares it), tagged @one-shot, or in the baseline's `files` list.",
"check:wall-wysiwyg": "the Wall's screen IS its poster: the Bless view's on-screen poster and its print come from one layout, so for every preset (3x4ft, 2x3ft, 5x8ft) the sheets, the 2,107 card rects, the card text, the verse reference and the chrome agree. Two-sided on planted layout defects. Headless.",
"check:wall-print-watch": "the Wall's HD poster print never prints silently incomplete: with fake timers and a fake frame, the watch counts photos as they load, counts the ones that could not, and reports both. Two-sided on planted printWatch copies. Headless.",
"check:wall-no-bg-photos": "the Wall's store downloads no photos on its own (it used to test all 932, 132 MB, on every visit): the REAL store runs through Vite against the committed fixtures/pray-list-sample.json with fetch stubbed and every Image request counted. Two-sided with the old background test planted. Headless, no network.",
@ -635,7 +636,8 @@
"check:poster-render-pinned": "the map-sheet generator a browser imports at print time (served at assets/lib/poster-render.js, not bundled) is integrity-pinned like every embed, because it executes on partner pages: the pin in library/packet/poster-render.integrity.js equals the sha384 of the source and of the served copy (a stale pin names `bun run pin:poster-render`), a TAMPERED copy is refused and its code never runs (a planted side effect stays unset), and the genuine copy loads and draws.",
"pin:poster-render": "rewrite library/packet/poster-render.integrity.js from library/packet/poster-render.js — run after any change to the generator; check:poster-render-pinned fails until it is.",
"check:board-map-options": "the GEOGRAPHIC map caller must state whiteboard/boundaries EXPLICITLY, never inherit them. library/maps/whiteboard-map.js defaults to whiteboard:true/boundaries:false (it was extracted for the whiteboard case), the inverse of what a real map needs, so a caller relying on defaults renders a blank basemap. check:gospel-whiteboard cannot see this — it asserts chart geometry and imports no component.",
"check:popup-pan-controls": "the popup auto-pan reserves the map's bottom control band, so a popup that clears the CLIP is also clear of the things that take the click. Measured at 390 on a 366x732 map: the pan fitted the popup inside the container, cut 0, and a real click on Full Profile still did nothing because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner (and span.pin-hint-pill on the sibling pin) - a cut-height check cannot see this, because the cut really was zero. Attribution is a licence term of the basemap, so the popup may not be raised over it; the usable frame is shorter than the container and the fit test says so. Runs the SHIPPED panPlanFor, read out of the SFC with new Function (it is not exported), never a re-typed copy, and asserts where the popup ENDS UP rather than re-deriving the expected number from the same formula. Sweeps band heights 0-120px and every width the component renders at. Source-only, headless."
"check:popup-pan-controls": "the popup auto-pan reserves the map's bottom control band, so a popup that clears the CLIP is also clear of the things that take the click. Measured at 390 on a 366x732 map: the pan fitted the popup inside the container, cut 0, and a real click on Full Profile still did nothing because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner (and span.pin-hint-pill on the sibling pin) - a cut-height check cannot see this, because the cut really was zero. Attribution is a licence term of the basemap, so the popup may not be raised over it; the usable frame is shorter than the container and the fit test says so. Runs the SHIPPED panPlanFor, read out of the SFC with new Function (it is not exported), never a re-typed copy, and asserts where the popup ENDS UP rather than re-deriving the expected number from the same formula. Sweeps band heights 0-120px and every width the component renders at. Source-only, headless.",
"check:control-inset": "bottomControlInset MEASURES the map's real bottom control band, so the pan reserves the band the controls actually occupy. check:popup-pan-controls proves the ARITHMETIC; this proves the band is real. If the inset silently returned 0 -- a selector typo, an engine renaming its control classes, a control moving outside the container -- panPlanFor would get bottomInset 0, behave exactly as it did before the fix, and check:popup-pan-controls would still pass every case, because its arithmetic is still correct: the bug back with both gates green. 8 cases against a real DOM, running the SHIPPED bottomControlInset read out of the SFC (it is not exported), never a copy: the band equals what the DOM independently says (not merely non-zero); no controls reserves 0; a zero-sized control reserves nothing; the taller of two corners wins; a wrapped attribution reserves more than one line; a control outside the container is ignored; a null container returns 0; and the MAPBOX corners count too, since the component takes a provider prop and every other case renders MapLibre names. Needs a browser: every assertion rests on getBoundingClientRect. 75 = no slot, never a pass."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -31,6 +31,7 @@ import { computed, inject, ref, onMounted, watch } from 'vue'
import RegionCountryMap from '@map/components/maps/RegionCountryMap.vue'
import { toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
import { peopleGroupPoints, PEOPLE_GROUP_POINT_FIELDS } from '@map/utils/peopleGroupPoints.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
const props = defineProps({
@ -99,7 +100,20 @@ async function loadPoints() {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const all = peopleGroupPoints(Array.isArray(data.posts) ? data.posts : [], toNumber)
// ★ THE HOST'S LIST IS APPLIED TO THE RAW POSTS, BEFORE THEY BECOME POINTS. `list` is a
// profile-config prop every other consumer of this endpoint honours — DataSourceManager,
// useDashboardData, useCsvExport, peopleGroupRecords and the people-groups grid all call
// applyPeopleGroupList on the rows they fetched. This element did not, so a host that asked for
// one curated list got the WHOLE endpoint drawn on its country map and nothing said so: the pins
// looked like data rather than like the wrong list. Applying it here, on data.posts rather than
// on the mapped points, is what keeps this element and a dashboard showing the same country from
// disagreeing about which groups are in it — the same reason the country filter below is
// client-side.
// The default list returns the rows untouched and makes no fetch, so an unconfigured host pays
// nothing; a list whose membership cannot be loaded yields NO groups rather than the wrong ones,
// which is applyPeopleGroupList's own contract and the safer failure for a prayer surface.
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], cfg.value)
const all = peopleGroupPoints(posts, toNumber)
// Filter HERE rather than asking the API for one country: the endpoint returns the whole list
// and every other consumer in this repo filters client-side, so a country page and a dashboard
// showing the same country cannot disagree about which groups are in it.

File diff suppressed because one or more lines are too long

View file

@ -70,7 +70,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-country-map> custom element. -->
<script src="../country-map.js" integrity="sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG" crossorigin="anonymous"></script>
<script src="../country-map.js" integrity="sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -171,7 +171,7 @@
"bundle": "country-map",
"scriptUrl": "./country-map/country-map.js",
"output": "dx-maps/country-map/country-map.js",
"integrity": "sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG",
"integrity": "sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5",
"elements": [
"doxa-country-map"
],
@ -190,8 +190,8 @@
"scriptUrl": "./country-map/country-map.js",
"embed": {
"iframe": "<iframe src=\"./country-map/country-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
}
}
]

View file

@ -171,7 +171,7 @@
"bundle": "country-map",
"scriptUrl": "./country-map/country-map.js",
"output": "dx-maps/country-map/country-map.js",
"integrity": "sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG",
"integrity": "sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5",
"elements": [
"doxa-country-map"
],
@ -190,8 +190,8 @@
"scriptUrl": "./country-map/country-map.js",
"embed": {
"iframe": "<iframe src=\"./country-map/country-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-aJ/I39YAhzKlQNUEeI0jsEytEOO0ZZ+/9Ww13BzQeZfhK4yGbMcv6YRu4l8oh3VG\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-map/country-map.js\" integrity=\"sha384-F40ik9qoPSDMeBTy9qjzRPsAOu7iAlhl7hkN8qN1E3kjprS4Rg7j7Eha/hxHuoO5\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<doxa-country-map profile-config='{\"profile\":\"country-map\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-country-map>"
}
}
]