Merge main into bundler-2/work

# Conflicts:
#	public/doxa-maps/index.html
This commit is contained in:
doxa-maps-88655c70 2026-09-24 16:17:18 +00:00
commit e466ba51de
18 changed files with 438 additions and 85 deletions

View file

@ -16,9 +16,19 @@
# today: every real build is serialized only by the local flock in
# internal/build-lock.sh. Do not rely on this file to prevent a concurrent-build
# race until a runner is registered AND a run is observed in the Actions tab.
# (The actions/runners endpoints 404 on this Forgejo version, so runner state was
# not directly measurable — the 0-runs figure is the load-bearing fact, and it
# holds regardless: 138 pushes with a working runner would have produced runs.)
# RE-VERIFIED 2026-09-24: still total_count 0. The 0-runs figure is the
# load-bearing fact and it holds — pushes with a working runner would have
# produced runs.
# ⚠ CORRECTION to what this header used to say. It claimed "the actions/runners
# endpoints 404 on this Forgejo version, so runner state was not directly
# measurable". Only the LISTING endpoint 404s. Both registration-token endpoints
# return 200 and issue a 40-char token on demand (measured 2026-09-24):
# /api/v1/repos/doxa-life/doxa-maps/actions/runners/registration-token
# /api/v1/admin/runners/registration-token
# So registering a runner is API-reachable, not a blocked unknown — which matters,
# because "not measurable" reads as "nothing to try" and that is why this sat.
# The step-by-step is in the handoff doc cited below, which EXISTS as of
# 2026-09-24: it was referenced twice here and had never been written.
#
# THE CONTRACT for a swarm agent:
# edit files -> git commit -> git push (DONE. never run `bun run build`.)
@ -83,6 +93,21 @@ jobs:
- name: Install dependencies (locked)
run: bun install --frozen-lockfile
# ★ BUILD ONE PACKET BEFORE THE GATES, so check:packets-all has something to check.
# library/scripts/posters/out/ is gitignored — both the records cache and every
# generated packet — so a clean CI checkout had NONE, that sweep SKIPped on every
# single build, and every packet assertion in this tree (the 12 prayer points, the
# wheel's 12 labels, page 1's geography, the print layer) was never once evaluated
# here. A SKIP that is always taken is indistinguishable from an unwired gate.
# It builds from a 20KB COMMITTED fixture: no API, no network, no photo fetch. The
# packet stamps itself "fixture nomap" (CI has no poster sources either), which the
# gate reads and accepts as declared — while still asserting everything that does
# not depend on live data. It runs BEFORE build:unlocked because gates:post is
# inside that script.
- name: Build one packet from the committed fixture
run: bun run packet:ci
# THE AUTHORITATIVE BUILD. build:unlocked is the real chain (clean -> token
# -> per-bundle vite -> tree -> drop/parse checks -> verify -> gallery). We
# call :unlocked, NOT `bun run build`, because CI IS the single builder now —

View file

@ -49,7 +49,7 @@ The scripts these commands are grounded in (from this branch's `package.json`):
"build": "bash internal/build-lock.sh",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source",
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
"preview": "bun x vite preview",
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production

View file

@ -46,9 +46,25 @@ const UA = 'Mozilla/5.0 (compatible; doxa-maps-gate/1.0)'
// the number ONLY inside explanatory comments — no shipping code hardcodes a corpus total. Every
// legend and KPI computes from the rows it loaded. The corpus moving 2107 -> 2108 therefore changes
// what the maps DISPLAY automatically, and only this baseline lagged.
// Re-measured 2026-09-24 (THIRD reading). The corpus has now moved 2107 -> 2108 -> 2107, and one
// group crossed from Unengaged to Engaged: 2081/27 -> 2079/28. Sums check (2079 + 28 = 2107).
//
// ★ THE SAME SAFETY CHECK WAS REPEATED, not assumed from last time. The gate's own failure text
// forbids updating this baseline alone, because that silences the check while leaving the maps
// wrong. So the maps were checked FIRST: grepping 2107/2108/2,107/2,108/2081/2079 across
// plugin-designs/ and library/ finds them ONLY inside explanatory comments — no shipping code
// hardcodes a corpus total, every legend and KPI computes from the rows it loaded. The corpus
// figure was also confirmed INDEPENDENTLY against the live endpoint (2107 rows) rather than
// taken from this gate's own reading.
//
// ⚠ THIS DRIFTS, AND THAT IS THE POINT. Three readings in two days, moving in both directions.
// A failure here is normally UPSTREAM DATA MOVING, not a regression — but it must still be seen,
// because the one thing it would also catch is a legend that stopped computing and started
// asserting. Do not widen this into a range to stop the noise: the noise IS the signal that the
// numbers on the page are live.
const EXPECT = {
total: 2108,
engagement: { Unengaged: 2081, Engaged: 27 },
total: 2107,
engagement: { Unengaged: 2079, Engaged: 28 },
adoptedPct: 1,
}

View file

@ -37,10 +37,26 @@ const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
// page actually loaded rather than a second copy.
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
'..', 'library/scripts/posters/packet-strings.json')
// The POSTER zone whose map page 1 shows. ISO3 selects the people groups; the poster is
// keyed by ZONE id, and they are not the same string (ISO3 'LAO' vs zone 'laos').
const ZONE = process.env.PACKET_ZONE || 'laos'
const LANG = process.argv[2] || 'en'
await pg.goto(ORIGIN + '/')
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
// ⚠ SKIP CLEANLY WHEN THE PRECONDITION IS ABSENT. This needs a static server over the
// REPO ROOT and a reachable records API; without either it threw an unhandled navigation
// error and dumped a stack trace, which is why it could never join a wave — a gate that
// goes red with a stack on a machine that simply has no server running only teaches
// people to disable it. Absence of a server is not a product failure; say so and exit 0.
try {
await pg.goto(ORIGIN + '/', { timeout: 8000 })
} catch {
await b.close()
console.log(`PACKET-BROWSER: SKIP — nothing served at ${ORIGIN}.`)
console.log(' This gate exercises the BROWSER export path, so it needs the repo root served:')
console.log(' python3 -m http.server 4898 (or set PACKET_ORIGIN)')
process.exit(0)
}
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE}) => {
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
import(RENDER),
fetch(STRINGS).then(r=>r.json()),
@ -55,8 +71,24 @@ const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}
const groups = recs.filter(r=>String(r.country_code)===ISO3)
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
// ⚠ FETCH THE MAP THE WAY THE PRODUCT DOES. This passed '' for mapSvg, so the
// browser packet had NO page-1 map and any geography assertion would have been
// measuring the HARNESS's shortcut rather than the product. The real export path
// (upg-100-list.vue:packetMapSvg) fetches posters/<zone>.html and lifts its <svg>,
// returning '' on failure — a legitimate degraded state, the browser twin of the
// build-time `nomap`. Exercise that code shape here instead of skipping it.
let mapSvg = ''
try {
const html = await (await fetch(`${ORIGIN}/library/scripts/posters/out/posters/${ZONE}.html`)).text()
const m = html.match(/<svg[\s\S]*?<\/svg>/)
if (m) {
mapSvg = `<style>${R.mapCss(html)}</style>`
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
+ `transform:scale(0.62);transform-origin:top center">${m[0]}</div>`
}
} catch { /* no poster generated — page 1 renders without its map, as in the product */ }
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, mapSvg)
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE})
await pg.setContent(html, { waitUntil:'load' })
await pg.waitForTimeout(3000)
@ -76,6 +108,40 @@ const r = await pg.evaluate(()=>({
// on a CORRECTLY translated packet (the same trap already fixed in
// check-packet-render).
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
// ★ THE SAME PACKET, EXPORTED THROUGH A DIFFERENT PATH, WAS HELD TO A WEAKER STANDARD.
// check-packet-render gained these over several ticks — the 12 points, the wheel's 12
// LABELS (not just its 12 segments), page 1's geography — each found by mutation, each
// catching a real "looks fine, is empty" shape. None of them existed here, and THIS is
// the packet a reader actually gets: the legend's Export > Prayer packet action. The
// build-time file is the one nobody clicks.
// Counted the same way as the build-time gate, deliberately, so the two cannot disagree.
points: (() => {
const t = document.querySelector('.points-title')
const host = t && t.parentElement && t.parentElement.parentElement
if (!host) return -1
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
})(),
// A segment without its word is a colour. `order` renders 12 from 12 keys, but `praise`
// and `praise_end` collapse to one WORD in English — so count ELEMENTS, never distinct
// words, or the expected number changes per language.
wheelLabels: (() => {
const t = document.querySelector('.wheel-title')
const sheet = t && t.closest('.sheet')
const svg = sheet && sheet.querySelector('svg')
if (!svg) return -1
return [...svg.querySelectorAll('text')]
.map((e) => (e.textContent || '').trim())
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
})(),
// Page 1 is the map one-pager. Palette-resolves and not-black are BOTH satisfied by a
// map with zero country shapes, so count the coastlines: paths whose `d` is long enough
// to be geography rather than an icon.
mapGeoPaths: (() => {
const sheet0 = document.querySelector('.sheet')
const svg = sheet0 && sheet0.querySelector('svg')
if (!svg) return -1
return [...svg.querySelectorAll('path')].filter((e) => (e.getAttribute('d') || '').length > 200).length
})(),
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
firstCard: (document.querySelector('.card')||{}).innerText||'',
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
@ -85,12 +151,23 @@ const pdf = await pg.pdf({ preferCSSPageSize:true })
const t = pdf.toString('latin1')
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
// THE PRINT LAYER. This gate already made a PDF and read only its STRUCTURE — page count
// and MediaBox — which eight blank sheets of the right size satisfy perfectly. The packet
// exists to be PRINTED, and a print-only CSS rule can strip content every on-screen check
// still sees. FontFile2 = a face actually embedded, so the text travels with the file.
const pdfFonts=(t.match(/\/FontFile2/g)||[]).length
const pdfBytes=pdf.length
await b.close()
console.log(` lang : ${LANG}`)
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` people cards : ${r.cards}`)
console.log(` wheel segments : ${r.wheel}`)
console.log(` Mark 5 in full : ${r.mark5}`)
console.log(` prayer points : ${r.points} / 12`)
console.log(` wheel segments : ${r.wheel} (${r.wheelLabels}/12 labelled)`)
console.log(` map (page 1) : ${r.mapGeoPaths} geography paths`)
console.log(` printed artefact : ${(pdfBytes/1024/1024).toFixed(1)}MB, ${pdfFonts} embedded font(s)`)
console.log(` Mark 5 in full : ${r.mark5Len} chars`) // ⚠ mark5Len, not mark5 — the latter is not a key on r and printed "undefined"
// beside a PASS for as long as this gate has existed. The ASSERTION below always read
// the right key, so the requirement was protected; only the display was blind.
console.log(` metacamp.org : ${r.metacamp}`)
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
console.log(` points heading : ${r.pointsTitle.trim()}`)
@ -107,6 +184,37 @@ if (!r.sheets) fails.push('no .sheet elements — the browser packet did not ren
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be one page`)
if (!r.cards) fails.push('no people-group cards')
if (r.wheel !== 12) fails.push(`prayer wheel has ${r.wheel} segments, expected 12`)
// THE PRODUCT IS NAMED FOR THIS NUMBER: the page header reads "12 Prayer Points" in every
// locale, so 12 is a contract. The failure it guards is silent — a partial translation once
// rendered THREE under that heading, with nothing blank and no error.
if (r.points !== 12) {
fails.push(r.points === -1
? 'no .points-title block — the 12 prayer points page did not render at all'
: `${r.points} prayer point(s) rendered, expected 12`)
}
// A wheel with 12 coloured segments and no words on it is a pie chart. The document-wide
// rhythm vocabulary cannot see this: the SAME words are rendered twice, once per segment
// and once per prayer-point heading, so a whole-document search passes on the points page.
if (r.wheelLabels !== 12) {
fails.push(r.wheelLabels === -1
? 'no prayer-wheel svg found — the wheel page did not render'
: `the prayer wheel paints ${r.wheelLabels} segment label(s), expected 12`)
}
// Page 1 is the map one-pager — the first line of the packet spec.
if (r.mapGeoPaths !== -1 && r.mapGeoPaths < 5) {
fails.push(`only ${r.mapGeoPaths} geography path(s) on page 1 — the map one-pager is blank `
+ 'or drew nothing but furniture')
}
// A missing map is a DECLARED degradation here, exactly as PACKET_EXPECT_MAP=0 is for the
// build-time gate: out/posters/ is gitignored, so a checkout that never ran `bun run posters`
// legitimately has none, and the product itself returns '' in that case.
if (r.mapGeoPaths === -1) {
console.log(` map (page 1) : ABSENT — no out/posters/${ZONE}.html (run \`bun run posters\`)`)
}
// The packet is a standalone file meant to print offline, so its text must travel with it.
if (pdfFonts === 0) {
fails.push('the PDF embeds NO fonts (0 /FontFile2) — the text depends on the printer\'s font set')
}
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
// Mark 5:1-20 in full, by LENGTH rather than an English proper noun.
if (r.mark5Len < 1200) {

View file

@ -346,7 +346,9 @@ if (r.cards && r.imgs + r.nophoto !== r.cards) {
}
// The packet's own stamp is authoritative; the env var stays as an override for
// artefacts generated before data-packet-build existed.
const expectPhotos = process.env.PACKET_EXPECT_PHOTOS !== '0' && !/\bnophotos\b/.test(build)
// `fixture` implies no photos: a fixture packet is built from a committed records subset
// with image_url stripped precisely so CI never fetches ~43 images from someone else's CDN.
const expectPhotos = process.env.PACKET_EXPECT_PHOTOS !== '0' && !/\b(nophotos|fixture)\b/.test(build)
if (r.cards && expectPhotos && r.imgs === 0) {
fails.push(`${r.cards} people cards and NOT ONE photo — the packet spec is "20 per page `
+ 'WITH IMAGES", and every source record carries an image_url, so this is a skipped or '

View file

@ -18,8 +18,28 @@ import { chromium } from 'playwright'
// `new URL(request.url())` below throws and the host tally stays EMPTY while the
// gate happily reports 0 mapbox requests and PASSES. Measured: it did exactly
// that, hiding 4 real requests to events.mapbox.com.
const LEAF = process.argv[2]
if (!LEAF) { console.error('usage: check-provider-switch.mjs <leafUrl>'); process.exit(2) }
// ⚠ SKIP CLEANLY, IN THE FORM THE WAVE RUNNER RECOGNISES. This exited 2 on a missing
// argument, so it could never join a wave: run-parallel grades on the exit code and a
// bare `usage:` line would have turned every server-less build RED. It also had no
// default, so it only ever ran when somebody typed a URL — the same present-but-never-
// executed shape check:packets-all had. Default to the upg-100 leaf (the profile this
// gate was written for) and SKIP with a named verdict when nothing is served there.
const DEFAULT_LEAF = (process.env.BASE_URL || 'http://127.0.0.1:4173')
+ '/doxa-maps/my-upg-100-list/upg-100-list/index.html'
const LEAF = process.argv[2] || DEFAULT_LEAF
{
let reachable = false
try {
const res = await fetch(LEAF, { signal: AbortSignal.timeout(4000) })
reachable = res.ok
} catch { /* nothing served — that is a precondition, not a product failure */ }
if (!reachable) {
console.log(`PROVIDER-SWITCH: SKIP — nothing served at ${LEAF}.`)
console.log(' Serve the built tree and re-run, e.g.:')
console.log(' python3 -m http.server 4173 --directory public (or set BASE_URL)')
process.exit(0)
}
}
async function run(provider) {
const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
// generate-packet-fixture — a small COMMITTED records subset, so CI can build a packet.
//
// ★ WHY THIS EXISTS. library/scripts/posters/out/ is gitignored — both the records cache
// and every generated packet — so on a clean checkout check:packets-all finds nothing and
// SKIPs. MEASURED 2026-09-24: CI runs that sweep on every build through gates:post, and it
// has SKIPped every time. Every packet assertion in this tree — the 12 prayer points, the
// wheel's 12 labels, page 1's geography paths, the print layer, the build stamp — has never
// once been evaluated in CI. A SKIP that is always taken is indistinguishable from an
// unwired gate, and the wave-membership census cannot tell them apart because membership
// measures INVOCATION, not EVALUATION.
//
// ★ WHY A FIXTURE RATHER THAN A LIVE FETCH. Building a real packet needs 2,107 records
// (887KB, live data that goes stale) and, with photos, ~95 CDN requests. Neither belongs in
// every CI run. This carries ONE zone's records — the smallest, Tibet at 43 slugs, 20KB —
// with image_url stripped so it can never reach for a photo.
//
// ★ AND IT IS DERIVED, NOT HAND-MAINTAINED. Membership comes from clusters.json, which IS
// committed, so this can be regenerated and content-compared (check:packet-fixture) exactly
// like the capability-exemplar table. A fixture nobody can verify is a fixture that rots.
//
// ⚠ IT IS NOT A SUBSTITUTE FOR REAL PACKETS. PACKET_RECORDS is opt-in and never the default:
// a fixture packet proves the RENDERER and the GATE work end to end, not that today's live
// data is sound. The 11-locale sweep against real records stays the real verification.
//
// Usage: node internal/generate-packet-fixture.mjs [--check]
// (reads the live records cache; run after a normal `node library/scripts/posters/packet.mjs`)
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CACHE = join(ROOT, 'library/scripts/posters/out/_records.json')
const CLUSTERS = join(ROOT, 'plugin-designs/my-upg-100-list/data/clusters.json')
const OUT_DIR = join(ROOT, 'library/scripts/posters/fixtures')
const OUT = join(OUT_DIR, 'records-tibet.json')
// The smallest zone by slug count, so the fixture stays small. Its membership lives in
// clusters.json; if that zone is ever renamed this script fails loudly rather than
// silently emitting an empty fixture.
const ZONE = 'asia-china-tibet-autonomous-region'
const CHECK = process.argv.includes('--check')
if (!existsSync(CLUSTERS)) {
console.log('PACKET-FIXTURE: SKIP — no clusters.json.')
process.exit(0)
}
const clusters = JSON.parse(readFileSync(CLUSTERS, 'utf8'))
const zones = [...(clusters.lists || []), ...(clusters.asia_lists || []), ...(clusters.uncovered_lists || [])]
const zone = zones.find((z) => z && z.id === ZONE)
if (!zone || !zone.selector || zone.selector.type !== 'pg_slugs') {
console.error(`PACKET-FIXTURE: FAIL — ${ZONE} is not a pg_slugs zone in clusters.json.`)
console.error(' The fixture is keyed to that zone; pick another and update ZONE here.')
process.exit(1)
}
const want = new Set(zone.selector.values || [])
// ── CHECK MODE: the committed fixture must still match clusters.json ────────
if (CHECK) {
if (!existsSync(OUT)) {
console.error(`PACKET-FIXTURE: FAIL — ${OUT.slice(ROOT.length + 1)} does not exist.`)
console.error(' Fix: node internal/generate-packet-fixture.mjs (then commit it)')
process.exit(1)
}
const have = JSON.parse(readFileSync(OUT, 'utf8'))
const haveSlugs = new Set(have.map((r) => r.slug))
const missing = [...want].filter((s) => !haveSlugs.has(s))
const extra = [...haveSlugs].filter((s) => !want.has(s))
const withPhotos = have.filter((r) => r.image_url).length
console.log(`PACKET-FIXTURE: ${have.length} record(s) for ${ZONE}.`)
const fails = []
if (missing.length) fails.push(`${missing.length} slug(s) in clusters.json are absent: ${missing.slice(0, 4).join(', ')}`)
if (extra.length) fails.push(`${extra.length} record(s) are no longer in the zone: ${extra.slice(0, 4).join(', ')}`)
// image_url must stay stripped, or CI starts fetching ~43 photos from someone else's CDN.
if (withPhotos) fails.push(`${withPhotos} record(s) still carry image_url — the fixture must never fetch photos`)
if (fails.length) {
console.error(`\nPACKET-FIXTURE: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
console.error('\n Fix: node internal/generate-packet-fixture.mjs (then commit it)')
process.exit(1)
}
console.log('PACKET-FIXTURE: PASS — the committed fixture matches clusters.json and fetches no photos.')
process.exit(0)
}
// ── GENERATE ────────────────────────────────────────────────────────────────
if (!existsSync(CACHE)) {
console.error('PACKET-FIXTURE: FAIL — no records cache to derive from.')
console.error(` Expected ${CACHE.slice(ROOT.length + 1)}.`)
console.error(' Run a normal packet build first: node library/scripts/posters/packet.mjs')
process.exit(1)
}
const recs = JSON.parse(readFileSync(CACHE, 'utf8'))
const subset = (Array.isArray(recs) ? recs : recs.records || []).filter((r) => r && want.has(r.slug))
if (subset.length !== want.size) {
console.error(`PACKET-FIXTURE: FAIL — matched ${subset.length} of ${want.size} slug(s) in the cache.`)
console.error(' The cache is stale or partial; re-run a full packet build and try again.')
process.exit(1)
}
// STRIP image_url. A fixture that carries photo URLs would make CI fetch ~43 images from
// Joshua Project's CDN on every build, which is both slow and rude; the packet degrades to
// typographic cards, which is exactly what a fixture should exercise.
const lean = subset.map((r) => {
const c = { ...r }
delete c.image_url
c.has_photo = false
return c
})
mkdirSync(OUT_DIR, { recursive: true })
writeFileSync(OUT, JSON.stringify(lean))
const kb = (JSON.stringify(lean).length / 1024).toFixed(0)
console.log(`[packet-fixture] wrote ${OUT.slice(ROOT.length + 1)} — ${lean.length} records for ${ZONE}, ${kb}KB`)

View file

@ -133,10 +133,20 @@ async function main() {
if (!step) return
const r = await runStep(step)
results.push(r)
// ★ A SKIP IS NOT A PASS, AND THIS PRINTED THEM IDENTICALLY. The grade is the exit
// code, and a gate that declines to evaluate exits 0 — so "35/35 passed" read the
// same whether 35 gates graded something or 17 did. MEASURED 2026-09-24: 18 of the
// 35 steps in gates:post CAN skip, every one of them on a precondition (a served
// tree, generated posters, a built manifest) that a fresh checkout does not have.
// check:packets-all SKIPped on every CI build for exactly this reason and nobody
// could see it, because the wave reported it green. An always-taken SKIP is
// indistinguishable from an unwired gate unless the runner says so.
const skipped = /(^|\n)\s*[A-Z][A-Z0-9-]*:\s*SKIP\b/.test(r.out)
console.log(
` ${r.code === 0 ? '✓' : '✗'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
` ${r.code !== 0 ? '✗' : skipped ? '⊘' : '✓'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` +
(r.code === 0 ? '' : ` exit ${r.code}`)
)
r.skipped = skipped
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, steps.length) }, worker))
@ -150,10 +160,20 @@ async function main() {
// a truncated explanation is how a real defect gets re-diagnosed as a flake.
else for (const r of failed) console.log(`\n───── FAILED: ${r.name} (exit ${r.code}) ─────\n${r.out}`)
// COUNT THE SKIPS IN THE SUMMARY, because the summary is the line a reader quotes and
// the one that lands in a report. "35/35 passed" was true of a wave where a third of the
// gates declined to look at anything.
const skips = results.filter((r) => r.skipped)
console.log(
`\n▶ ${LABEL}: ${results.length - failed.length}/${results.length} passed in ${(wall / 1000).toFixed(1)}s wall ` +
`\n▶ ${LABEL}: ${results.length - failed.length - skips.length}/${results.length} passed` +
(skips.length ? `, ${skips.length} SKIPPED` : '') +
` in ${(wall / 1000).toFixed(1)}s wall ` +
`(${(serial / 1000).toFixed(1)}s of work — ${(serial / Math.max(wall, 1)).toFixed(1)}x)`
)
if (skips.length) {
console.log(` ⊘ evaluated nothing: ${skips.map((s) => s.name).join(', ')}`)
console.log(' Each declined on a precondition it names in its own output (run it alone to see).')
}
if (failed.length) {
console.error(`✗ ${LABEL}: ${failed.length} step(s) FAILED — ${failed.map((f) => f.name).join(', ')}`)
process.exit(1)

File diff suppressed because one or more lines are too long

View file

@ -52,8 +52,14 @@ const LANG = process.env.PACKET_LANG || 'en';
const ONLY = process.env.ONLY || '';
const NOPHOTOS = process.env.NOPHOTOS === '1';
const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
if (!API) {
console.error('[packet] No API base — set VITE_API_BASE_URL. Failing closed.');
// FAIL CLOSED without an API — EXCEPT when records come from a committed fixture, which
// is the one case where no API is needed at all. Measured 2026-09-24: this guard runs
// BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a
// tree that had everything it needed. It only passed in hand-testing because I had been
// supplying a dummy host — the real invocation path is what exposed it.
if (!API && !process.env.PACKET_RECORDS) {
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.');
console.error(' Failing closed.');
process.exit(1);
}
@ -109,6 +115,21 @@ const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
async function loadRecords() {
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
// records cache AND every generated packet — so on a clean checkout check:packets-all
// finds nothing and SKIPs. MEASURED 2026-09-24: CI runs the sweep on EVERY build via
// gates:post and it has SKIPped every single time, which means every packet assertion in
// this tree (the 12 points, the wheel labels, page 1's geography, the print layer, the
// build stamp) has never once been evaluated there. A SKIP that is always taken is
// indistinguishable from an unwired gate, and the wave-membership census cannot see the
// difference because membership measures INVOCATION, not EVALUATION.
// PACKET_RECORDS points at a small committed subset instead of the live API or the
// gitignored cache. It is deliberately NOT the default: real packets must use real data.
const fixture = process.env.PACKET_RECORDS;
if (fixture) {
if (!existsSync(fixture)) throw new Error(`PACKET_RECORDS set but missing: ${fixture}`);
return JSON.parse(readFileSync(fixture, 'utf8'));
}
const cache = join(__dirname, 'out', '_records.json');
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
@ -232,7 +253,13 @@ for (const z of zones) {
// session, packet-strings.json was 303s newer than the packets while being byte-identical
// to HEAD, because a probe restored it from backup. Record the INTENT, which is a fact
// about the build, not the clock.
const provenance = [NOPHOTOS ? 'nophotos' : null, mapMiss ? 'nomap' : null]
// `fixture` is a build fact like the others: a fixture packet is built from a committed
// records subset with image_url stripped, so it legitimately has no photos and its
// records are not today's live data. Without this the gate reports "every source record
// carries an image_url, so this is a skipped or failed photo fetch" — a true sentence
// about real records and a false one about this packet.
const provenance = [process.env.PACKET_RECORDS ? 'fixture' : null,
NOPHOTOS ? 'nophotos' : null, mapMiss ? 'nomap' : null]
.filter(Boolean).join(' ') || 'full';
const stamped = html.replace('<html ', `<html data-packet-build="${provenance}" `);
writeFileSync(join(OUT, name), stamped);

View file

@ -35,6 +35,9 @@
"posters:export": "cd library/scripts/posters && node export-public.mjs",
"posters:previews": "cd library/scripts/posters && python3 make-previews.py",
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs # the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",
"packet-fixture": "node internal/generate-packet-fixture.mjs # regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check # the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
"packet:ci": "sh -c 'F=\"$PWD/library/scripts/posters/fixtures/records-tibet.json\"; PACKET_RECORDS=\"$F\" ONLY=asia-china-tibet-autonomous-region node library/scripts/posters/packet.mjs' # build ONE packet from the committed fixture, no network. This is what makes check:packets-all evaluate something in CI instead of SKIPping, as it had on every build.",
"token": "node internal/write-token.mjs",
"smoke": "node internal/smoke.mjs",
"smoke:embeds": "node internal/embed-smoke.mjs",
@ -46,7 +49,7 @@
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune # destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
"check:label-joins": "node internal/check-label-joins.mjs",
"check:dxlife": "node internal/check-dxlife-untouched.mjs",
"check:dxlife": "node internal/check-dxlife-untouched.mjs # ⚠ NOT WAVE-SAFE, deliberately unwired: exit codes 0/1/2 are a DOCUMENTED contract (0 unchanged · 1 drift · 2 clone not found), and run-parallel grades any non-zero as FAILURE — so a checkout without the sibling /root-dx-life clone goes red on a precondition. Changing the contract could break another consumer; hand-run it, or give it a recognised SKIP first.",
"check:facet-cascade": "node internal/check-facet-cascade.mjs # a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
"check:facet-one-field": "node internal/check-facet-one-field.mjs # ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
@ -62,12 +65,12 @@
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
"check:poster-prints": "node internal/check-poster-prints.mjs",
"check:provider-switch": "node internal/check-provider-switch.mjs",
"check:provider-switch": "node internal/check-provider-switch.mjs # the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:zone-census": "node internal/check-zone-census.mjs",
"check:zone-census": "node internal/check-zone-census.mjs # every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs # the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",
@ -154,7 +157,7 @@
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
"check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:legend-source",
"build:bundles": "node internal/run-parallel.mjs --bundles",
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
"check:tabs-localized": "node internal/check-tabs-localized.mjs # a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",

View file

@ -489,6 +489,22 @@ function writeUrl(push) {
if (!URL_SYNC) return
const slug = currentSlug()
const url = window.location.pathname + window.location.search + (slug ? '#' + encodeURIComponent(slug) : '')
// WRITE ONLY WHEN THE URL WOULD ACTUALLY CHANGE. This is what makes Back and Forward work, and
// it replaces a flag that could not do the job. MEASURED before the fix, drilling
// All Regions -> Africa -> Angola and then pressing Back three times and Forward twice:
// back 1 -> Africa (correct)
// back 2 -> Africa (WRONG, should be All Regions)
// back 3 -> Africa
// forward 1 -> Africa (WRONG, should be Angola)
// history.length stayed 4 throughout: every Back pushed a REPLACEMENT entry, which discards the
// forward stack and pins the position — the classic loop the note below warns about.
// The old guard set applyingHistory=true, assigned sel, then cleared it SYNCHRONOUSLY, while
// the watcher that calls this is { flush: 'post' } and therefore runs after the DOM update,
// by which time the flag was already false. A scheduling-dependent flag cannot guarantee this;
// comparing the URL can. After a popstate the address bar already holds the target, so there is
// nothing to write and no entry is created.
const current = window.location.pathname + window.location.search + window.location.hash
if (url === current) { syncTitle(); return }
try { push ? history.pushState({ slug }, '', url) : history.replaceState({ slug }, '', url) } catch (_) {}
syncTitle()
}
@ -508,20 +524,23 @@ function syncTitle() {
try { document.title = t && t !== allLabel ? t + ' — ' + suffix : suffix } catch (_) {}
}
// popstate WRITES the selection, so the watcher below must not turn around and push a new
// entry for it — that is the classic history loop where Back appears to do nothing.
let applyingHistory = false
// popstate WRITES the selection, and the watcher below then runs — but it can no longer create an
// entry for it, because writeUrl() refuses to write a URL the address bar already holds. The flag
// that used to guard this is GONE rather than patched: it was cleared synchronously while the
// watcher it guarded is { flush: 'post' }, so it was never in effect when it mattered.
function onPopState() {
applyingHistory = true
sel.value = selectionForSlug(slugFromHash())
syncTitle()
applyingHistory = false
}
// One watcher rather than a writeUrl() call at each of the five places a selection is made
// (tab-1 event, table row, region dropdown, country dropdown, back-link) — those would drift.
watch(() => [sel.value.regionSlug, sel.value.countrySlug], () => {
if (applyingHistory) return
// No guard needed: writeUrl() is idempotent, so the call this watcher makes after a popstate
// writes nothing. (Deleting the declaration while leaving `if (applyingHistory) return` here
// threw a ReferenceError inside the watcher, which silently stopped the URL being written at
// all — the hash never changed and Back left the page. Caught by re-running the measurement
// rather than by trusting the fix.)
writeUrl(true)
}, { flush: 'post' })

File diff suppressed because one or more lines are too long

View file

@ -874,7 +874,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/",
"integrity": "sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj",
"elements": [
"regions-dashboard"
],
@ -894,7 +894,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -908,7 +908,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -922,7 +922,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-gdsNwbs4RZH3pLoW4Q7VYk3IzEL0BOAzNqmjVCQY1WfgvXCx6oPhZBs3nnE9bXQ/" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-oXKY7GNXVKcivqGSmhcCZeEPtmSfjR+UNiGyvnqBrJ/gZtJD61rOA8T+SA0SlHQj" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: