diff --git a/internal/check-packet-map-missing-loud.mjs b/internal/check-packet-map-missing-loud.mjs
new file mode 100644
index 000000000..09e3bbc56
--- /dev/null
+++ b/internal/check-packet-map-missing-loud.mjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * check-packet-map-missing-loud — page 1 of a prayer packet must SAY when its map is missing.
+ *
+ * The packet's first page draws the zone map from `mapSvg`. When the map fetch fails — which every
+ * build did until the dx-maps config shipped the poster sources (posterSources), and any build that
+ * turns them off still does — the page used to interpolate an empty string: a title, a subtitle, a
+ * privacy footnote, and nothing where the map belongs. A failed fetch and a successful render were
+ * BYTE-INDISTINGUISHABLE in the printed PDF, and the packet read as undeveloped.
+ *
+ * ★ TWO-SIDED, because a one-sided test here passes a constant:
+ * (1) mapSvg EMPTY → the notice appears, in the map's place, with real text (not the key)
+ * (2) mapSvg PRESENT → the notice does NOT appear, and the SVG is on the page untouched
+ * A renderer that ALWAYS printed the notice would pass (1) alone; one that never did would pass (2)
+ * alone. Only both together describe "loud exactly when missing".
+ *
+ * Also asserts the per-key fallback: a locale with no translation for the notice (every locale but
+ * English, by design) prints the English sentence rather than the raw key or nothing. A notice that
+ * rendered as `map_unavailable` would be louder, but it would be wrong in a different way.
+ *
+ * Runs in both variants: lightweight keeps page 1, so it must be loud too.
+ *
+ * Exit 0 pass · 1 fail · 75 NOT MEASURED.
+ */
+import { readFileSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const HERE = dirname(fileURLToPath(import.meta.url))
+const REPO = resolve(HERE, '..')
+
+let createPacketRenderer, strings
+try {
+ ;({ createPacketRenderer } = await import(join(REPO, 'library/packet/packet-render.js')))
+ strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8'))
+} catch (e) {
+ console.error(`MAP-MISSING-LOUD: NOT MEASURED (exit 75) — the renderer or its strings did not load: ${e.message}`)
+ process.exit(75)
+}
+
+const EN_NOTICE = strings.en?.map_unavailable
+if (!EN_NOTICE) {
+ console.error('MAP-MISSING-LOUD: FAIL — packet-strings.json en has no map_unavailable sentence, so there is')
+ console.error(' nothing for page 1 to say. The notice must be English-first; other locales fall back to it.')
+ process.exit(1)
+}
+
+const zone = { id: 'probe-zone', label: 'Probe Zone' }
+// A recognisable SVG so side (2) can prove the REAL map survives, not just that the notice is absent.
+const SVG = ''
+
+/** The HTML of page 1 only — everything before the second sheet. */
+function page1(html) {
+ const starts = [...html.matchAll(/
/g)].map((m) => m.index)
+ if (!starts.length) return ''
+ return html.slice(starts[0], starts[1] ?? html.length)
+}
+
+const fails = []
+const notes = []
+
+for (const lang of ['en', 'es', 'ar']) {
+ const R = createPacketRenderer({ strings, lang })
+ for (const variant of ['full', 'lightweight']) {
+ const tag = `${lang}/${variant}`
+
+ // (1) MISSING → loud.
+ const empty = page1(R.buildPacket(zone, [], new Map(), '', null, null, variant, null))
+ if (!empty) { fails.push(`${tag}: no page 1 rendered at all`); continue }
+ if (!empty.includes('class="map-missing"')) {
+ fails.push(`${tag}: mapSvg empty, but page 1 has NO missing-map notice — the silent cover this gate exists to prevent`)
+ } else if (empty.includes('map_unavailable')) {
+ fails.push(`${tag}: the notice printed the raw KEY "map_unavailable" instead of a sentence`)
+ } else if (!empty.includes(EN_NOTICE) && lang === 'en') {
+ fails.push(`${tag}: the notice text is not the English sentence from packet-strings.json`)
+ }
+
+ // (2) PRESENT → quiet, and the real map is on the page.
+ const full = page1(R.buildPacket(zone, [], new Map(), SVG, null, null, variant, null))
+ if (full.includes('class="map-missing"')) {
+ fails.push(`${tag}: mapSvg PRESENT, yet page 1 still prints the missing-map notice — a notice that always shows says nothing`)
+ }
+ if (!full.includes('class="probe-map"')) {
+ fails.push(`${tag}: mapSvg present but the SVG is NOT on page 1 — the fix displaced the map it was guarding`)
+ }
+ }
+ notes.push(` ok ${lang.padEnd(4)} full + lightweight: loud when missing, quiet when present`)
+}
+
+// Per-key fallback, stated separately because it is a property of the STRINGS, not the renderer:
+// every non-English locale deliberately lacks the key and must inherit the English sentence.
+const missingIn = Object.keys(strings).filter((k) => k !== '_meta' && k !== 'en' && !strings[k].map_unavailable)
+notes.push(` ok ${missingIn.length} locales fall back to the English sentence (no invented translations)`)
+
+for (const n of notes) console.log(n)
+if (fails.length) {
+ console.error('MAP-MISSING-LOUD: FAIL')
+ for (const f of fails) console.error(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log('MAP-MISSING-LOUD: PASS — page 1 names a missing map and stays quiet when the map is there, in en/es/ar, both variants.')
diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js
index 9f25f15d2..5b24deef6 100644
--- a/library/packet/packet-render.js
+++ b/library/packet/packet-render.js
@@ -251,6 +251,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
/* FINE PRINT — product decision: attribution minimal, fine print small. */
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
font:400 13px Georgia,serif;color:#a89f8c}
+ /* THE MAP IS MISSING, and this says so where the map would have been.
+ ⚠ Centred in the SAME region the map occupies, not tucked into the fine print: a reader
+ comparing two packets must be able to see at a glance which one lost its illustration.
+ Quiet, not alarming — it is a missing picture in an otherwise complete document, so it reads
+ as a caption rather than an error. No border and no icon for the same reason: on a printed
+ page an error box costs ink and implies the whole packet is suspect. */
+ .map-missing{position:absolute;left:${M}px;right:${M}px;top:50%;transform:translateY(-50%);
+ text-align:center;font:italic 19px Georgia,serif;color:#a89f8c}
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
@@ -328,10 +336,45 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
`);
}
+ /**
+ * What page 1 shows when the zone map could not be loaded.
+ *
+ * ★ THE POINT IS THAT IT SAYS SOMETHING. `${mapSvg}` interpolated an empty string, so a failed
+ * map fetch and a successful render were BYTE-INDISTINGUISHABLE in the printed PDF: a cover with
+ * a title, a subtitle, a privacy footnote, and 600 bytes of nothing where the map belongs. The
+ * packets read as undeveloped, and this page is why — the packet WAS built, and its first page
+ * silently lost its content.
+ *
+ * ⚠ THE SILENCE IS UPSTREAM AND DELIBERATE, WHICH IS WHY THE FIX BELONGS HERE. The browser path
+ * (usePosterPrints.js) wraps the poster fetch in `catch (_) { /* a cover without the map beats no
+ * packet *\/ }`, and that judgement is RIGHT — a missing map must not cost the reader 20 pages of
+ * people to pray for. But "degrade" and "degrade silently" are different things, and only the
+ * renderer knows the map is absent at the moment it draws the page.
+ *
+ * MEASURED, and it was not a rare edge: until the dx-maps config turned posterSources on, the tree
+ * shipped NO posters/ directory (generate-tree gates it behind posterSources/bundlePosters, both
+ * default false). Over live HTTP against the served tree: previews/sudan.webp 200,
+ * boundaries/world-adm0-web.geojson 200, posters/sudan.html 404, so EVERY packet's page 1 was blank.
+ * The sources ship now (check:poster-print-fallback asserts it); this notice is for any build
+ * that turns them off, and for a fetch that fails anyway.
+ *
+ * Why a notice and not a thrown error: the packet is still worth printing, and a partner holding
+ * it should be able to SEE that one illustration is missing rather than wonder whether the
+ * project ships half-finished documents. This is the fail-loud half of fail-soft.
+ *
+ * ⚠ English-only key, on purpose. `t()` falls back to English per key, so the other ten locales
+ * print this sentence in English until a translator supplies their own — which is honest. The
+ * alternative was inventing ten translations I cannot verify, and a wrong sentence reads as
+ * AUTHORITATIVE to someone who cannot check it.
+ */
+ function missingMapNotice() {
+ return `
${esc(t('map_unavailable'))}
`;
+ }
+
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
//
// WHY THIS PAGE EXISTS. Driver 2026-09-26, via the shepherd: the packets go to ALL partners for
diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json
index c28d9fa3d..23ce3cef4 100644
--- a/library/scripts/posters/packet-strings.json
+++ b/library/scripts/posters/packet-strings.json
@@ -10,6 +10,7 @@
"en": {
"map_page_kicker": "UNENGAGED PEOPLE GROUPS",
"map_page_sub": "A prayer list of {count} unreached people groups",
+ "map_unavailable": "The map for this zone is not included in this printing.",
"list_header": "{from}–{to} of {count} · pray for them by name",
"people_suffix": "people",
"no_photo": "no photo available",
diff --git a/package.json b/package.json
index 64fba4833..73c0e83b3 100644
--- a/package.json
+++ b/package.json
@@ -198,6 +198,7 @@
"check:packet-charts": "node internal/check-packet-charts.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
+ "check:packet-map-missing-loud": "node internal/check-packet-map-missing-loud.mjs",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:packet-render:expect-funnel": "node internal/check-packet-render.mjs --expect-funnel",
"check:series-isolation": "node internal/check-series-isolation.mjs",
@@ -300,7 +301,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived 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:zoning-declared 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:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback",
- "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:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard",
+ "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:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@@ -467,6 +468,7 @@
"check:no-repo-pb-hooks": "no PocketBase hooks (pb_hooks/, *.pb.js) in the repo: the cloud container pulls it live, so a pushed hook would be server-side JS from any writer; the one team sign-in hook is baked into the image",
"check:packet-browser": "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-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
+ "check:packet-map-missing-loud": "page 1 of a prayer packet SAYS when its zone map is missing, instead of printing a blank cover: two-sided (the notice appears when mapSvg is empty and is absent when the map is present, SVG intact), in en/es/ar and both variants, and a locale without the key prints the English sentence, never the raw key. Node only, no browser.",
"check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.",
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
"check:packet-variants": "the poster wall's Prayer List prints the PACKET (not the columnar list), and every variant it offers exists in packet-render's VARIANTS. A variant may drop fixed pages but never people: lightweight keeps every group. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.",