diff --git a/internal/check-packet-variants.mjs b/internal/check-packet-variants.mjs
index 8f79b9c96..d7dcf0d41 100644
--- a/internal/check-packet-variants.mjs
+++ b/internal/check-packet-variants.mjs
@@ -58,7 +58,16 @@ const groups = Array.from({ length: 45 }, (_, i) => ({ slug: `g${i}`, name: `Gro
const zone = { id: 'test', label: 'Test Zone' }
const funnel = { steps: [{ label: 'a', groups: 12379 }, { label: 'b', groups: 2107 }] }
const christian = { pctEvangelical: 3.2 }
-const build = (v) => R.buildPacket(zone, groups, new Map(), '', funnel, christian, v)
+// ★ THE SETS MUST BE SUPPLIED OR THIS GATE CANNOT SEE THE PAGE. setsPage returns '' without data —
+// correctly, so a host with no set data still gets a valid packet — which means a build() that
+// omits `sets` exercises the variant filter for every page EXCEPT the one most recently added. The
+// gate stayed green through the whole sets rewrite while asserting a 2-sheet delta that had become
+// 3. A fixture that is missing an input silently narrows what the whole file covers.
+const sets = {
+ bounded: Array.from({ length: 30 }, (_, i) => ({ id: `b${i}`, reached: i % 3 === 0 ? true : (i % 3 === 1 ? false : undefined) })),
+ centered: Array.from({ length: 24 }, (_, i) => ({ id: `c${i}`, ring: (i % 12) - 1 })),
+}
+const build = (v) => R.buildPacket(zone, groups, new Map(), '', funnel, christian, v, sets)
const count = (html, re) => (html.match(re) || []).length
const full = build('full')
@@ -74,8 +83,24 @@ ok(count(light, /sheet-life/g) === 0, 'lightweight still carries the life calend
ok(count(light, /sheet-christian/g) === 0, 'lightweight still carries the %-evangelical page')
// …and keeps the triangle, the profiles, the wheel and the points.
ok(count(light, /funnel-title/g) === count(full, /funnel-title/g), 'lightweight lost the funnel (the Driver\'s "triangle")')
-ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 2,
- `lightweight should be exactly 2 sheets shorter, got ${count(full, /class="sheet/g)} vs ${count(light, /class="sheet/g)}`)
+// …and the SETS page, which is back matter a reader sits with rather than carries — the same
+// reason the life calendar and the %-evangelical grid are dropped.
+ok(count(full, /sheet-sets/g) === 1, 'the full packet has no bounded/centered sets page')
+ok(count(light, /sheet-sets/g) === 0, 'lightweight still carries the sets page')
+// ⚠ THREE, not two: the delta grew when the sets page landed, and this number is meant to be
+// UPDATED in the commit that adds a page, never relaxed into a range. A tolerance here would make
+// the assertion unable to see the next page that goes missing.
+ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 3,
+ `lightweight should be exactly 3 sheets shorter, got ${count(full, /class="sheet/g)} vs ${count(light, /class="sheet/g)}`)
+// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no
+// x/y — deliberately, since `reached` undefined means we do not know which side they belong on and
+// drawing them anywhere would assert exactly what is unknown. On a printed page there is no hover,
+// so a category the reader cannot see reads as zero. It must appear in the tally text.
+{
+ const caps = full.match(/class="set-cap">([^<]*)${esc(t('points_attrib'))}`);
}
+ // ── PAGE n+1: the BOUNDED and CENTERED sets ─────────────────────────────────
+ // Brief A1 BACK MATTER: after the profiles, before the wheel. FULL DETAIL ONLY — the lightweight
+ // packet is map · triangle · profiles · wheel · points, and these two figures are the part a
+ // reader sits with rather than carries.
+ //
+ // The LAYOUTS come from regions' library/components/charts modules, imported directly rather than
+ // injected: unlike describeGroup's country resolver they have ZERO imports and run under plain
+ // node (verified by importing and calling them, not by reading them), and packet-render already
+ // imports scatterCells from that same directory. They return DATA — points with x/y in unitless
+ // SVG user coordinates centred on (cx,cy) — so the markup below is ours.
+ //
+ // ⚠ GOSPEL'S NON-NEGOTIABLE, carried through regions: the bounded set keeps a deliberate gap
+ // either side of the boundary (inside within 0.92r, outside beyond 1.12r) because a dot sitting ON
+ // the line is the one thing a bounded set cannot express, and PRINT HAS NO HOVER to disambiguate
+ // it. If the figure does not fit, SHRINK THE WHOLE FIGURE — never compress the gap. Measured on
+ // the real module with 40 groups: inside max 85.3 of 92 allowed, outside min 119.8 of 112
+ // required. The scale below is applied to the viewBox, so it cannot touch the gap.
+ function setsPage(zone, sets) {
+ // '' WITHOUT DATA, exactly like funnelPage and christianPage — a page that cannot state true
+ // figures must not appear at all. This is what keeps FULL valid before a host supplies sets.
+ if (!sets || (!Array.isArray(sets.bounded) && !Array.isArray(sets.centered))) return '';
+ const bounded = Array.isArray(sets.bounded) ? sets.bounded : [];
+ const centered = Array.isArray(sets.centered) ? sets.centered : [];
+ if (!bounded.length && !centered.length) return '';
+
+ const usableW = W - 2 * M, colW = (usableW - 60) / 2;
+ const parts = [];
+
+ if (bounded.length) {
+ const L = layoutBoundedSet(bounded, { r: 100, spread: 220 });
+ const T = boundedSetTally(L);
+ // The viewBox is sized from the layout's own extent so the figure scales to the column
+ // without any coordinate being touched — the gap survives by construction.
+ // ⚠ UNKNOWN POINTS CARRY NO x/y, AND THAT IS THE MODULE BEING CORRECT. `reached` undefined
+ // means we do not know which side of the boundary a people belongs on, so there is no honest
+ // place to draw it — plotting it anywhere would assert the very thing that is unknown. My
+ // first version mapped over L.unknown with the same dot() helper and crashed on
+ // `p.x.toFixed`, which is a better outcome than silently drawing them at (0,0) inside the
+ // circle. They are STATED in the tally instead (regions: an un-inked category on a printed
+ // page, where there is no hover, reads as zero).
+ const plotted = [...L.inside, ...L.outside];
+ const ext = Math.max(...plotted.map((p) => Math.hypot(p.x - L.cx, p.y - L.cy)), L.r * 1.2) * 1.08;
+ const dot = (p, cls) => ``;
+ parts.push(`
+ `, 'sheet-sets');
+ }
+
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
// be translated, and this packet must generate in any language. Twelve equal
@@ -845,10 +950,15 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// never drop a page whose count depends on the data.
const VARIANTS = {
full: null, // null = every page (today's packet, unchanged)
- lightweight: new Set(['life', 'christian']), // page KEYS to omit
+ // page KEYS to omit. 'sets' joins them: the bounded/centered figures are BACK MATTER a reader
+ // sits with, which is the same reason the life calendar and the %-evangelical grid are dropped.
+ lightweight: new Set(['life', 'christian', 'sets']),
};
- function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null, variant = 'full') {
+ // `sets` is the EIGHTH parameter and the last positional one this signature should ever grow — it
+ // is already at the edge of readable. A ninth belongs in an options object; flagged here so the
+ // next person adding a page makes that change rather than extending the line again.
+ function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null, variant = 'full', sets = null) {
// ★ PAGE ORDER IS THE DRIVER'S, 2026-09-27, and it is an argument rather than a layout:
// 1 map — where this zone is
// 2 life calendar — 100 years x 52 weeks, a whole human life on one page
@@ -872,6 +982,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
keep('funnel') && funnelPage(zone, groups, funnel),
// NOT behind keep() — see the VARIANTS comment: dropping list pages drops people.
...listPages(zone, groups, photos),
+ // BACK MATTER: after the profiles, before the wheel (brief A1). Full-detail only.
+ keep('sets') && setsPage(zone, sets),
keep('points') && pointsPage(),
keep('wheel') && wheelPage(),
].filter(Boolean);
diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json
index eecc163e9..cfaa4b56e 100644
--- a/library/scripts/posters/packet-strings.json
+++ b/library/scripts/posters/packet-strings.json
@@ -211,7 +211,14 @@
"desc_gsec_3": "Fewer than 2% are evangelical, and scattered church planting has begun among them.",
"desc_gsec_4": "Between 2% and 5% are evangelical.",
"desc_gsec_5": "Between 5% and 10% are evangelical.",
- "desc_gsec_6": "More than 10% are evangelical."
+ "desc_gsec_6": "More than 10% are evangelical.",
+ "sets_kicker": "Who is near, and who is not",
+ "sets_title": "Two ways to see the same people",
+ "sets_lede": "The circle asks whether a people has been reached. The rings ask how far they sit from someone who could reach them.",
+ "sets_bounded_title": "Reached, and not",
+ "sets_centered_title": "Distance from a neighbour",
+ "sets_bounded_tally": "{inside} inside · {outside} outside · {unknown} unknown",
+ "sets_centered_tally": "{placed} placed · {centre} at the centre · {unplaced} unplaced"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@@ -417,7 +424,14 @@
"desc_gsec_3": "Menos del 2% evangélico, y entre ellos ha comenzado una plantación de iglesias dispersa.",
"desc_gsec_4": "Entre el 2% y el 5% son evangélicos.",
"desc_gsec_5": "Entre el 5% y el 10% son evangélicos.",
- "desc_gsec_6": "Más del 10% son evangélicos."
+ "desc_gsec_6": "Más del 10% son evangélicos.",
+ "sets_kicker": "Quién está cerca y quién no",
+ "sets_title": "Dos maneras de ver al mismo pueblo",
+ "sets_lede": "El círculo pregunta si un pueblo ha sido alcanzado. Los anillos preguntan a qué distancia está de alguien que podría alcanzarlo.",
+ "sets_bounded_title": "Alcanzados y no alcanzados",
+ "sets_centered_title": "Distancia a un vecino",
+ "sets_bounded_tally": "{inside} dentro · {outside} fuera · {unknown} desconocido",
+ "sets_centered_tally": "{placed} ubicados · {centre} en el centro · {unplaced} sin ubicar"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@@ -623,7 +637,14 @@
"desc_gsec_3": "Moins de 2% d’évangéliques, et une implantation d’Églises dispersée a commencé parmi eux.",
"desc_gsec_4": "Entre 2% et 5% sont évangéliques.",
"desc_gsec_5": "Entre 5% et 10% sont évangéliques.",
- "desc_gsec_6": "Plus de 10% sont évangéliques."
+ "desc_gsec_6": "Plus de 10% sont évangéliques.",
+ "sets_kicker": "Qui est proche, et qui ne l’est pas",
+ "sets_title": "Deux façons de voir le même peuple",
+ "sets_lede": "Le cercle demande si un peuple a été atteint. Les anneaux demandent à quelle distance il se trouve de quelqu’un qui pourrait l’atteindre.",
+ "sets_bounded_title": "Atteints, ou non",
+ "sets_centered_title": "Distance d’un voisin",
+ "sets_bounded_tally": "{inside} à l’intérieur · {outside} à l’extérieur · {unknown} inconnu",
+ "sets_centered_tally": "{placed} placés · {centre} au centre · {unplaced} non placés"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@@ -827,7 +848,14 @@
"desc_gsec_3": "Menos de 2% evangélicos, e uma plantação de igrejas dispersa começou entre eles.",
"desc_gsec_4": "Entre 2% e 5% são evangélicos.",
"desc_gsec_5": "Entre 5% e 10% são evangélicos.",
- "desc_gsec_6": "Mais de 10% são evangélicos."
+ "desc_gsec_6": "Mais de 10% são evangélicos.",
+ "sets_kicker": "Quem está perto e quem não está",
+ "sets_title": "Duas formas de ver o mesmo povo",
+ "sets_lede": "O círculo pergunta se um povo foi alcançado. Os anéis perguntam a que distância ele está de alguém que poderia alcançá-lo.",
+ "sets_bounded_title": "Alcançados e não alcançados",
+ "sets_centered_title": "Distância de um vizinho",
+ "sets_bounded_tally": "{inside} dentro · {outside} fora · {unknown} desconhecido",
+ "sets_centered_tally": "{placed} posicionados · {centre} no centro · {unplaced} sem posição"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@@ -1033,7 +1061,14 @@
"desc_gsec_3": "Unter 2% evangelikal, und vereinzelt hat unter ihnen die Gemeindegründung begonnen.",
"desc_gsec_4": "Zwischen 2% und 5% sind evangelikal.",
"desc_gsec_5": "Zwischen 5% und 10% sind evangelikal.",
- "desc_gsec_6": "Mehr als 10% sind evangelikal."
+ "desc_gsec_6": "Mehr als 10% sind evangelikal.",
+ "sets_kicker": "Wer ist nah, und wer nicht",
+ "sets_title": "Zwei Blicke auf dasselbe Volk",
+ "sets_lede": "Der Kreis fragt, ob ein Volk erreicht wurde. Die Ringe fragen, wie weit es von jemandem entfernt ist, der es erreichen könnte.",
+ "sets_bounded_title": "Erreicht und nicht erreicht",
+ "sets_centered_title": "Abstand zu einem Nachbarn",
+ "sets_bounded_tally": "{inside} innen · {outside} außen · {unknown} unbekannt",
+ "sets_centered_tally": "{placed} platziert · {centre} in der Mitte · {unplaced} ohne Platz"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@@ -1237,7 +1272,14 @@
"desc_gsec_3": "Meno del 2% evangelici, e tra loro è iniziata una fondazione di chiese sparsa.",
"desc_gsec_4": "Tra il 2% e il 5% sono evangelici.",
"desc_gsec_5": "Tra il 5% e il 10% sono evangelici.",
- "desc_gsec_6": "Più del 10% sono evangelici."
+ "desc_gsec_6": "Più del 10% sono evangelici.",
+ "sets_kicker": "Chi è vicino e chi no",
+ "sets_title": "Due modi di vedere lo stesso popolo",
+ "sets_lede": "Il cerchio chiede se un popolo è stato raggiunto. Gli anelli chiedono quanto dista da qualcuno che potrebbe raggiungerlo.",
+ "sets_bounded_title": "Raggiunti e non raggiunti",
+ "sets_centered_title": "Distanza da un vicino",
+ "sets_bounded_tally": "{inside} dentro · {outside} fuori · {unknown} sconosciuto",
+ "sets_centered_tally": "{placed} collocati · {centre} al centro · {unplaced} non collocati"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@@ -1441,7 +1483,14 @@
"desc_gsec_3": "Sub 2% evanghelici, iar printre ei a început o plantare de biserici răzleață.",
"desc_gsec_4": "Între 2% și 5% sunt evanghelici.",
"desc_gsec_5": "Între 5% și 10% sunt evanghelici.",
- "desc_gsec_6": "Peste 10% sunt evanghelici."
+ "desc_gsec_6": "Peste 10% sunt evanghelici.",
+ "sets_kicker": "Cine este aproape și cine nu",
+ "sets_title": "Două feluri de a vedea același popor",
+ "sets_lede": "Cercul întreabă dacă un popor a fost atins. Inelele întreabă cât de departe este de cineva care l-ar putea atinge.",
+ "sets_bounded_title": "Atinse și neatinse",
+ "sets_centered_title": "Distanța față de un vecin",
+ "sets_bounded_tally": "{inside} înăuntru · {outside} în afară · {unknown} necunoscut",
+ "sets_centered_tally": "{placed} plasate · {centre} în centru · {unplaced} neplasate"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@@ -1647,7 +1696,14 @@
"desc_gsec_3": "Менее 2% евангельских верующих, и среди них местами началось основание церквей.",
"desc_gsec_4": "От 2% до 5% — евангельские верующие.",
"desc_gsec_5": "От 5% до 10% — евангельские верующие.",
- "desc_gsec_6": "Более 10% — евангельские верующие."
+ "desc_gsec_6": "Более 10% — евангельские верующие.",
+ "sets_kicker": "Кто рядом, а кто нет",
+ "sets_title": "Два взгляда на один народ",
+ "sets_lede": "Круг спрашивает, достигнут ли народ. Кольца спрашивают, как далеко он от того, кто мог бы его достичь.",
+ "sets_bounded_title": "Достигнутые и нет",
+ "sets_centered_title": "Расстояние до соседа",
+ "sets_bounded_tally": "{inside} внутри · {outside} снаружи · {unknown} неизвестно",
+ "sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@@ -1853,7 +1909,14 @@
"desc_gsec_3": "أقل من 2% إنجيليين، وقد بدأ زرع متفرق للكنائس بينهم.",
"desc_gsec_4": "ما بين 2% و5% إنجيليون.",
"desc_gsec_5": "ما بين 5% و10% إنجيليون.",
- "desc_gsec_6": "أكثر من 10% إنجيليون."
+ "desc_gsec_6": "أكثر من 10% إنجيليون.",
+ "sets_kicker": "من قريب ومن ليس كذلك",
+ "sets_title": "طريقتان لرؤية الشعب نفسه",
+ "sets_lede": "الدائرة تسأل إن كان الشعب قد وصلته البشارة. والحلقات تسأل كم يبعد عمّن يستطيع الوصول إليه.",
+ "sets_bounded_title": "وصلتهم البشارة ومن لم تصلهم",
+ "sets_centered_title": "المسافة إلى جار",
+ "sets_bounded_tally": "{inside} في الداخل · {outside} في الخارج · {unknown} غير معروف",
+ "sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@@ -2057,7 +2120,14 @@
"desc_gsec_3": "2% से कम सुसमाचारी, और उनके बीच छिटपुट कलीसिया स्थापना शुरू हो चुकी है।",
"desc_gsec_4": "2% से 5% के बीच सुसमाचारी हैं।",
"desc_gsec_5": "5% से 10% के बीच सुसमाचारी हैं।",
- "desc_gsec_6": "10% से अधिक सुसमाचारी हैं।"
+ "desc_gsec_6": "10% से अधिक सुसमाचारी हैं।",
+ "sets_kicker": "कौन निकट है और कौन नहीं",
+ "sets_title": "एक ही जन को देखने के दो तरीके",
+ "sets_lede": "वृत्त पूछता है कि क्या किसी जन तक सुसमाचार पहुँचा है। वलय पूछते हैं कि वे उस से कितनी दूर हैं जो उन तक पहुँच सकता है।",
+ "sets_bounded_title": "पहुँचे और नहीं पहुँचे",
+ "sets_centered_title": "पड़ोसी से दूरी",
+ "sets_bounded_tally": "{inside} भीतर · {outside} बाहर · {unknown} अज्ञात",
+ "sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित"
},
"zh": {
"map_page_kicker": "未得之民",
@@ -2261,6 +2331,13 @@
"desc_gsec_3": "福音派不足 2%,他们当中已零星开始植堂。",
"desc_gsec_4": "福音派占 2% 至 5%。",
"desc_gsec_5": "福音派占 5% 至 10%。",
- "desc_gsec_6": "福音派超过 10%。"
+ "desc_gsec_6": "福音派超过 10%。",
+ "sets_kicker": "谁在近处,谁不在",
+ "sets_title": "看同一群人的两种方式",
+ "sets_lede": "圆圈问的是这个族群是否已被福音触及。同心环问的是他们离能够触及他们的人有多远。",
+ "sets_bounded_title": "已触及与未触及",
+ "sets_centered_title": "与邻近族群的距离",
+ "sets_bounded_tally": "{inside} 圈内 · {outside} 圈外 · {unknown} 未知",
+ "sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位"
}
}