diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs
index 3038f5306..02116aa96 100644
--- a/internal/check-packet-render.mjs
+++ b/internal/check-packet-render.mjs
@@ -215,6 +215,11 @@ const r = await page.evaluate(() => {
return {
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
+ // The page-2 funnel is OPTIONAL (buildPacket omits it when no funnel data was supplied), so its
+ // presence is DETECTED rather than assumed — see the sheet arithmetic below, which must stay
+ // exact about list pages whether or not this page is there.
+ funnel: document.querySelectorAll('.funnel-title').length,
+ funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says
// pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a
// BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds,
@@ -562,12 +567,29 @@ if (!boxes.length) {
// page arithmetic only if the pages drop too) and a mis-sized grid, which is the way
// "20 per page WITH IMAGES" actually regresses. Verified against all 11 locale packets
// of laos (95 cards -> 8 sheets) before being turned on.
+//
+// ⚠ THE PAGE-2 FUNNEL IS COUNTED, NOT TOLERATED. When it was added (2026-09-26) every fixture went
+// red here with "43 cards should lay out as 6 sheets, but the packet has 7" — the gate doing its job
+// on a structural change it had not been told about. The wrong fix is to accept N or N+1 sheets:
+// this assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS, and a ±1 tolerance is exactly the
+// slack a lost list page would hide in. So the funnel is DETECTED (.funnel-title) and added as a
+// known term, leaving the list-page arithmetic as strict as it was.
const PER_PAGE = 20
-const expectedSheets = 1 + Math.ceil(r.cards / PER_PAGE) + 2
+const funnelSheets = r.funnel ? 1 : 0
+const expectedSheets = 1 + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
- + `(1 map + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), but the packet has ${r.sheets} — `
- + `either groups were dropped from a page or the ${PER_PAGE}-per-page grid changed`)
+ + `(1 map${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
+ + `but the packet has ${r.sheets} — either groups were dropped from a page or the `
+ + `${PER_PAGE}-per-page grid changed`)
+}
+// ★ AND IF THE FUNNEL PAGE IS THERE, IT MUST CARRY ITS NUMBERS. A sheet holding the title and no
+// figures would satisfy the arithmetic above perfectly — the same "a number printed but not asserted
+// on is decoration" trap the card-name check above exists for, one page earlier. .funnel-mine is the
+// reader's own share, the one figure on the page that is about THIS packet.
+if (r.funnel && !r.funnelSteps) {
+ fails.push('the funnel page rendered its title but no share figure (.funnel-mine) — '
+ + 'page 2 is asserting a narrowing it never states')
}
// ── ...AND THAT ALONE IS NOT ENOUGH: IT ONLY PROVES SELF-CONSISTENCY ────────
diff --git a/library/composables/usePosterPrints.js b/library/composables/usePosterPrints.js
index eb625b40f..97cee56ef 100644
--- a/library/composables/usePosterPrints.js
+++ b/library/composables/usePosterPrints.js
@@ -224,7 +224,10 @@ export function usePosterPrints(opts = {}) {
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
// only the bundle knows where its posters are served from. If it cannot be
// fetched the packet still prints — a cover without the map beats no packet.
- async function printPacket(item, { mapSvg = '' } = {}) {
+ // `funnel` is the page-2 narrowing data (see packet-render.funnelPage). It is passed THROUGH
+ // untouched: this composable does not know how a host defines its lists, and deriving the counts
+ // here would make a second source of truth for numbers the Vision Card already publishes.
+ async function printPacket(item, { mapSvg = '', funnel = null } = {}) {
if (busy()) return;
const kind = 'Prayer Packet';
set(item, kind, 'fetch', 'fetching people groups', 30);
@@ -242,7 +245,7 @@ export function usePosterPrints(opts = {}) {
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
);
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
- openPrintFrame(R.buildPacket(item, data, photos, mapSvg), { autoprint: true });
+ openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel), { autoprint: true });
done(item, kind);
} catch (e) { fail(item, kind, e); }
}
diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js
index c9b99aac0..403ddf265 100644
--- a/library/packet/packet-render.js
+++ b/library/packet/packet-render.js
@@ -210,7 +210,103 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
${esc(t('privacy_note'))}
`);
}
- // ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
+ // ── 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
+ // the largest prayer campaign, and a partner holding one list needs to see where their list sits
+ // in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them
+ // unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand.
+ //
+ // ★ IT RENDERS NUMBERS IT DID NOT COMPUTE, ON PURPOSE. Every count is READ from the caller's
+ // `funnel` object, which comes from data/people-groups/vision-funnel.json — the file
+ // library/scripts/people-groups/build-vision-funnel.mjs generates from the IMB CSV and the live
+ // DOXA list, and which ALREADY feeds the Vision Card. Recomputing them here would create a second
+ // source of truth for the same fact, and the two would drift into "two honest surfaces that
+ // disagree" — each internally consistent, together telling a reader the project cannot count.
+ // If a number is wrong, it is wrong in the generator, and it is wrong in the Vision Card too.
+ //
+ // ⚠ NO STEP IS HARDCODED AND NEITHER IS THE NUMBER OF STEPS. `funnel.steps` is walked as given,
+ // so when the `list` prop lands (exp-shepherd, Tier A: profile-config.list / ?list= /
+ // window.MAP_LIST, default 'doxa') and the 7k / 4.7k / 700 lists plug in, their own step chains
+ // render with no change here. A step whose id has no string falls back to the id itself rather
+ // than rendering blank.
+ //
+ // ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists
+ // of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118,
+ // asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page
+ // never claims that band; it states the share the data can defend. Shepherd approved exactly this
+ // (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the Driver rules.
+ //
+ // ⚠ NUMERALS STAY WESTERN. num() is toLocaleString(lang), and for the bare `ar`/`hi` tags CLDR's
+ // default numbering system is `latn` — verified: 12,379 and 2,107 in both. Arabic-Indic digits
+ // need an explicit -u-nu-arab, which nothing here asks for. The brief requires numerals untouched,
+ // and reusing num() is also what keeps the funnel's digits identical to every other packet page.
+ function funnelPage(zone, groups, funnel) {
+ if (!funnel || !Array.isArray(funnel.steps) || !funnel.steps.length) return '';
+ const steps = funnel.steps;
+ const top = steps[0] && steps[0].groups;
+ // The widest bar is the first step; every bar is drawn proportional to it, with a floor so the
+ // smallest step is still a visible bar rather than a hairline.
+ const BAR_W = W - 2 * M - 300;
+ const width = (n) => Math.max(26, Math.round(BAR_W * (Number(n) / Number(top || 1))));
+
+ const rows = steps.map((s, i) => {
+ const label = t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id;
+ const pop = Number.isFinite(Number(s.population))
+ ? `
${num(s.population)} ${esc(t('funnel_people'))}
`
+ : '';
+ // The last IMB step is the network's own list — tint it so the eye lands where the reader is.
+ const fillCol = i === steps.length - 1 ? '#7a3b2e' : '#cdbf9e';
+ return `
+
${num(s.groups)}
+
${esc(label)}
+ ${pop}
+
+
`;
+ }).join('');
+
+ // THIS list, the step the reader is holding. `groups.length` is the packet's own row count, so
+ // the page cannot disagree with the pages that follow it.
+ const mine = groups.length;
+ const total = Number(funnel.listTotal ?? (steps[steps.length - 1] || {}).groups) || 0;
+ const pct = total ? (100 * mine / total) : 0;
+ // ⚠ A SHARE MUST NEVER ROUND TO ZERO — zero reads as "this packet covers none of them", which is
+ // false for every list. Measured against the real catalogue (counts 1..118 of 2,107): one decimal
+ // gave the single-group fixture "0.0%" when the true value is 0.0475%. So: integer at or above
+ // 10%, one decimal from 1%, two below that, then strip trailing zeros so 0.9967 prints "1" rather
+ // than "1.00". Verified across 1, 2, 8, 20, 21, 43, 95, 118 and 300 groups.
+ const pctStr = pct >= 10
+ ? String(Math.round(pct))
+ : pct.toFixed(pct >= 1 ? 1 : 2).replace(/\.?0+$/, '');
+
+ const share = funnel.listCount
+ ? `