diff --git a/package.json b/package.json
index 28614cc0a..1e5669c1f 100644
--- a/package.json
+++ b/package.json
@@ -347,7 +347,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:auto-engine check:engine-contract 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:no-silent-engine:self-test 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:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
- "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:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams",
+ "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:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams",
"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",
@@ -402,6 +402,7 @@
"check:wall-print-watch": "node plugin-designs/wall-of-people-groups/scripts/check-wall-print-watch.mjs",
"check:wall-no-bg-photos": "node plugin-designs/wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs",
"check:wall-pan": "node plugin-designs/wall-of-people-groups/scripts/check-wall-pan.mjs",
+ "check:wall-photo-weight": "node plugin-designs/wall-of-people-groups/scripts/check-wall-photo-weight.mjs",
"check:template-i18n": "node plugin-designs/template-bundle/scripts/check-template-i18n.mjs",
"check:snippets-draw": "node internal/check-snippets-draw.mjs",
"check:snippets-draw:mapbox": "node internal/check-snippets-draw.mjs --mapbox",
@@ -427,6 +428,7 @@
"check:wall-print-watch": "the Wall's HD poster print never prints silently incomplete: with fake timers and a fake frame, the watch counts photos as they load, counts the ones that could not, and reports both. Two-sided on planted printWatch copies. Headless.",
"check:wall-no-bg-photos": "the Wall's store downloads no photos on its own (it used to test all 932, 132 MB, on every visit): the REAL store runs through Vite against the committed fixtures/pray-list-sample.json with fetch stubbed and every Image request counted. Two-sided with the old background test planted. Headless, no network.",
"check:wall-pan": "the Wall pans and zooms with the pointer 1:1: the REAL WallCanvas methods (through Vite) with d3-zoom's transform arithmetic, over 12 fits and zooms: a pan moves every card exactly as far as the pointer, a zoom keeps the point under the pointer, toWorld inverts toScreen, and a fling never loses the wall. Two-sided: the old transform-inside-the-fit formulas are planted and caught. Headless.",
+ "check:wall-photo-weight": "the Wall's poster loads its photos at print size: the 77 IMB photos (1024 px, ~1.5 MB each, 114 MB of the print's 133 MB) map to their host's 320 px copies on either host, with the original as the onerror fallback, every other photo untouched, and the print HTML and the screen canvas both go through photoUrl.js. Two-sided (the old full-size URLs are caught). Headless.",
"check:template-i18n": "the starter template every new plugin copies renders its text from the catalogue: profile B, server-rendered with the REAL template.json in en, ar and zh, takes its heading, note and sentence from the page's language. Two-sided with the English catalogue served as Arabic. Headless (SSR).",
"check:coverage-consistent:chained": "every clause of check:coverage-consistent EXCEPT the link-verification age (VERIFY_MAX_AGE_DAYS set out of reach): the chains run what depends on the code and data, and leave what depends on the calendar to the on-demand run, so the gates cannot turn red on a date with no change.",
"check:viewport-meta": "every generated page declares with width=device-width. Without it a browser lays the page out at the 980px desktop fallback even when the viewport is set to 390, so a mobile probe silently measures a viewport no phone has and still passes (measured: innerWidth 980 without the tag, 390 with it). width=device-width is required specifically, because a meta carrying only initial-scale still lays out at 980. Checked on the BUILT tree, not the generator template. Fixed-size print sheets are exempt by their stated shape (a .stage with a three-digit px width), and a page that must render at a fixed desktop width carries a viewport-meta-ok: reason. 8 self-tests. Source-only, headless.",
diff --git a/plugin-designs/wall-of-people-groups/scripts/check-wall-photo-weight.mjs b/plugin-designs/wall-of-people-groups/scripts/check-wall-photo-weight.mjs
new file mode 100644
index 000000000..2a1f9d4a5
--- /dev/null
+++ b/plugin-designs/wall-of-people-groups/scripts/check-wall-photo-weight.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+/**
+ * check-wall-photo-weight — the Wall's poster loads its photos at the size it prints them. Headless.
+ *
+ * The 77 IMB photos (peoplegroupspublic.blob.core.windows.net, images.peoplegroups.org) are 1024 px originals of
+ * ~1.5 MB: 114 MB of the print's 133 MB, measured 2026-09-30, a minute or more before the print dialog can open
+ * on a home connection. photoUrl.js loads a 320 px copy from the host's own resizer instead (19-20 MB in all).
+ * This pins, on the REAL modules:
+ * - an IMB photo on EITHER host maps to images.peoplegroups.org/cdn-cgi/image/width=320 for the same file;
+ * - every other photo (Joshua Project, doxa) is untouched, and a non-URL stays as it is;
+ * - fullPhoto() gives the original back for a resized URL, and null for anything else;
+ * - the PRINT HTML (buildBlessPosterHtml) uses the resized copy, with the original as its onerror fallback,
+ * and leaves the other photos alone; the screen canvas loads through the same lightPhoto().
+ * Two-sided: lightPhoto replaced by the identity (the old behaviour) must fail the mapping and the print arms.
+ * EXIT 0 pass · 1 fail
+ */
+import { readFileSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../src')
+const P = await import(pathToFileURL(join(DIR, 'photoUrl.js')).href)
+const B = await import(pathToFileURL(join(DIR, 'blessPoster.js')).href)
+const canvasSrc = readFileSync(join(DIR, 'wallCanvas.js'), 'utf8')
+
+const IMB = ['https://peoplegroupspublic.blob.core.windows.net/imbpeople-featured-images/PG016542.jpg',
+ 'https://images.peoplegroups.org/imbpeople-featured-images/PG023972.jpg']
+const OTHER = ['https://media.joshuaproject.net/public/assets/media/profiles/photos/p10120.jpg',
+ 'https://joshuaproject.net/assets/media/profiles/photos/p20298.jpg', 'https://s3.doxa.life/no-photo-images/asia.jpg']
+const WANT = (file) => `https://images.peoplegroups.org/cdn-cgi/image/width=320,format=jpeg/imbpeople-featured-images/${file}`
+
+function run(light, full) {
+ const errs = []
+ for (const u of IMB) {
+ const file = u.split('/').pop()
+ if (light(u) !== WANT(file)) errs.push(`map: ${u} → ${light(u)}, want the 320 px copy`)
+ if (full(light(u)) !== `https://images.peoplegroups.org/imbpeople-featured-images/${file}`) errs.push(`full: no original for ${u}`)
+ }
+ for (const u of OTHER) { if (light(u) !== u) errs.push(`map: ${u} was rewritten`); if (full(u) !== null) errs.push(`full: ${u} is not a resized URL`) }
+ if (light('') !== '' || light('not a url') !== 'not a url') errs.push('map: a non-URL was changed')
+ // the print HTML, through the real engine, with this light()
+ const groups = [{ name: 'A', imageUrl: IMB[0] }, { name: 'B', imageUrl: OTHER[0] }, { name: 'C', imageUrl: '' }]
+ const html = B.buildBlessPosterHtml(groups, '3x4ft', {})
+ const imgs = [...html.matchAll(/]*)>/g)]
+ if (imgs.length !== 2) errs.push(`print: ${imgs.length} photos in the document, want 2`)
+ else {
+ if (imgs[0][1] !== light(IMB[0]) || !imgs[0][1].includes('/cdn-cgi/image/width=320')) errs.push(`print: the IMB photo prints from ${imgs[0][1]}`)
+ if (!/data-full="https:\/\/images\.peoplegroups\.org\/imbpeople-featured-images\/PG016542\.jpg"/.test(imgs[0][2]) || !/onerror=/.test(imgs[0][2])) errs.push('print: the resized photo has no fallback to its original')
+ if (imgs[1][1] !== OTHER[0] || /data-full/.test(imgs[1][2])) errs.push('print: a Joshua Project photo was changed')
+ }
+ return errs
+}
+
+const fails = []
+fails.push(...run(P.lightPhoto, P.fullPhoto))
+// the canvas loads through the same helper (both draw paths) and retries the original
+if ((canvasSrc.match(/lightPhoto\(g\.imageUrl\)/g) || []).length < 2) fails.push('canvas: wallCanvas.js does not load every card photo through lightPhoto()')
+if (!/fullPhoto\(u\)/.test(canvasSrc)) fails.push('canvas: a failed resized photo is not retried from its original')
+// SELF-TEST: the old behaviour (originals) must fail the mapping arm; the print arm reads the real engine, so
+// it is checked by running the engine's output against an identity map
+const planted = run((u) => u, () => null)
+if (!planted.some((e) => e.startsWith('map:'))) fails.push('SELF-TEST: originals (identity) were not caught by the mapping arm')
+if (!planted.some((e) => e.startsWith('print:') || e.startsWith('full:'))) fails.push('SELF-TEST: the print/fallback arms did not react to the identity map')
+
+if (fails.length) { console.log('WALL-PHOTO-WEIGHT: FAIL'); for (const f of fails) console.log(' - ' + f); process.exitCode = 1 }
+else console.log('WALL-PHOTO-WEIGHT: PASS — IMB photos on both hosts load as 320 px copies with their originals as the fallback, every other photo is untouched, and the print and the canvas both go through photoUrl.js; the old full-size behaviour is caught.')
diff --git a/plugin-designs/wall-of-people-groups/src/blessPoster.js b/plugin-designs/wall-of-people-groups/src/blessPoster.js
index ab52c2ccb..2ca0d2d60 100644
--- a/plugin-designs/wall-of-people-groups/src/blessPoster.js
+++ b/plugin-designs/wall-of-people-groups/src/blessPoster.js
@@ -21,6 +21,7 @@
* SAME layout for the screen (screenPoster: what the wall shows is what prints). No Vue.
*/
import { blessingFor, POSTER, THEMES } from './blessings.js'
+import { lightPhoto, fullPhoto } from './photoUrl.js'
// The sheets, their glue bands and the print marks: the shared panel geometry (also India's large posters).
import { planPanels, screenLayout, panelChromeHtml, panelCss } from '../../../library/components/poster/panelSheets.js'
@@ -252,7 +253,10 @@ function posterParts(groups, presetId, texts, withCards = true) {
const railLabel = (text) => `${esc(text)}`
const card = (c) => {
const g = groups[c.i], v = blessingFor(c.i)
- const img = g.imageUrl ? `
` : '
${Ae(Z.text)}
${Ae(Z.ref)}
`,X=`${Ae(l)}
`:"")+`${xe(Z.text)}
${xe(Z.ref)}
`,X=`${xe(l)}
`:"")+`