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
${esc(fill(t('map_page_sub'), { count: num(count) }))}
- ${mapSvg} + ${mapSvg || missingMapNotice()}
${esc(t('privacy_note'))}
`); } + /** + * 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.",