Say so on page 1 when a prayer packet's map is missing

Page 1 of every packet draws the zone map from mapSvg. When the map
fetch fails, the page interpolated 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.

It is not a rare edge. The served asset pool ships boundaries, fonts,
images, people-groups, pplr, previews, tables and vendor, and no
posters/ directory at all, because generate-tree gates it behind
posterSources and 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 on a
default build every packet's page 1 has been blank.

The browser path's catch around the poster fetch is right to keep
printing -- a missing map must not cost the reader the people pages --
but degrading and degrading silently are different things, and only
the renderer knows the map is absent when it draws the page. It now
prints one quiet sentence in the map's place.

The sentence is English-only on purpose. t() falls back to English per
key, so the other ten locales print it in English until a translator
supplies theirs, rather than ten invented translations nobody can
check.

The real fix for the blank map is shipping the poster sources in the
asset pool; that is a tree-config change and goes separately.

check-packet-map-missing-loud is two-sided: loud when the map is
missing AND quiet when it is present with the SVG intact, in en/es/ar,
both variants. Shown capable of failing four ways: the silent cover, a
notice that always prints, a notice that displaces the map, and the raw
key printed instead of a sentence.
This commit is contained in:
Doxa Maps 2026-09-28 23:47:16 +00:00
parent c5f9a22d0b
commit ef6000c5d7
4 changed files with 149 additions and 2 deletions

View file

@ -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 = '<svg class="probe-map" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
/** The HTML of page 1 only — everything before the second sheet. */
function page1(html) {
const starts = [...html.matchAll(/<div class="sheet[^"]*">/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.')

View file

@ -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
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
</div>
${mapSvg}
${mapSvg || missingMapNotice()}
<div class="fine">${esc(t('privacy_note'))}</div>`);
}
/**
* 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 `<div class="map-missing">${esc(t('map_unavailable'))}</div>`;
}
// ── 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

View file

@ -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",

View file

@ -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.",