diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 7007c7be6..1e40f88fc 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -237,6 +237,17 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) // 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 UNTOUCHED" IS ABOUT DIGITS, NOT GROUPING, and those are two properties. I verified the + // digits and assumed the rest. rega measured the rendered hi sheet and found Indian grouping — + // "8,28,85,63,969 लोग" (lakh/crore, 2-2-3) rather than Western 3-3-3. That is CORRECT hi-IN + // formatting, so changing it would be worse than leaving it, and the real risk was never the + // grouping itself but INCONSISTENCY: the same number printed two ways in one document. + // Measured 2026-09-26, and it is consistent: num() is the ONLY formatter in this renderer, so the + // funnel's populations and the people-group cards' populations group identically. Confirmed against + // the rendered hi packet — card populations 1,70,000 / 3,37,000 / 8,10,000 are Indian-grouped like + // the funnel's. (Only values at or above 100,000 can distinguish the two systems at all; my first + // check classified "1,400" and read a mismatch that did not exist.) + // // ⚠ 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, diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index aba301582..8723acc04 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -123,17 +123,24 @@ const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8')); // them, at build time, so no figure on the page is a typed constant. // A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could // not read. -const FUNNEL = (() => { +// ⚠ A FUNCTION, NOT A CONSTANT, because it depends on PACKET_LIST — which is resolved further down, +// after the records load. It mirrors upg-100-list.vue's packetFunnel() exactly: the DEFAULT list is the +// only one the zone catalogue describes, so it is the only one that may claim "1 of K lists" and an +// unassigned count; a non-default list states its OWN total and goes quiet on the rest. +function buildFunnel(membership) { try { const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8')); if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null; + if (membership) { + return { ...raw, listTotal: membership.total, listId: LIST, listLabel: membership.label }; + } const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []), ...(clusters.uncovered_lists || [])]; const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0; const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0); return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) }; } catch (_) { return null; } -})(); +} const { evaluate } = await import(join(DATA, 'selector-evaluator.js')); const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js')); @@ -229,7 +236,45 @@ async function buildPacket(zone, groups, mapSvg) { } // ── MAIN ──────────────────────────────────────────────────────────────────── -const records = await loadRecords(); +const allRecords = await loadRecords(); + +// ★ PACKET_LIST MAKES THE NON-DEFAULT-LIST PATH REACHABLE FROM THIS GENERATOR, and it needed to be. +// The BROWSER path honours the universal `list` prop — upg-100-list.vue calls applyPeopleGroupList() +// at every fetch site — but this generator had no list support at all, so the SIX-ROW funnel (a list's +// own total inserted before the reader's row) could not be rendered by the only pipeline rega can +// drive. Its only evidence was a harness I wrote about it, which is the state I have been objecting to +// elsewhere: a code path whose sole witness is its author's own test. +// +// ⚠ IT READS THE MEMBERSHIP FILE FROM DISK rather than through loadListMembership(), which fetches +// from the asset pool over HTTP — there is no server here. Same file, same `slugs`, same counts.doxa. +// ⚠ AND IT FAILS CLOSED. An unknown or unreadable list exits non-zero instead of quietly producing a +// packet of every group: peopleGroupList.js makes the same choice for the same reason ("a map that +// silently showed all 2,107 while an embed asked for Project 700 would be showing the wrong list with +// no sign of it"). A packet is worse — it is printed and handed to someone. +const LIST = process.env.PACKET_LIST || ''; +let LIST_MEMBERSHIP = null; +if (LIST) { + const mf = join(__dirname, `../../../data/people-groups/lists/${LIST}.json`); + if (!existsSync(mf)) { + console.error(`[packet] PACKET_LIST=${LIST}: no membership file at ${mf}`); + console.error(' Run library/scripts/people-groups/build-lists.mjs, or use the default list.'); + process.exit(1); + } + const m = JSON.parse(readFileSync(mf, 'utf8')); + LIST_MEMBERSHIP = { slugs: new Set(m.slugs || []), total: Number(m?.counts?.doxa) || (m.slugs || []).length, label: m.label || LIST }; + if (!LIST_MEMBERSHIP.slugs.size) { + console.error(`[packet] PACKET_LIST=${LIST}: membership file lists no slugs. Failing closed.`); + process.exit(1); + } +} +const records = LIST_MEMBERSHIP + ? allRecords.filter((r) => LIST_MEMBERSHIP.slugs.has(r.slug)) + : allRecords; +if (LIST_MEMBERSHIP) { + console.log(`[packet] list=${LIST} — ${records.length} of ${allRecords.length} record(s) ` + + `belong to it (list total ${LIST_MEMBERSHIP.total})`); +} +const FUNNEL = buildFunnel(LIST_MEMBERSHIP); const bySlug = new Map(records.map((r) => [r.slug, r])); const zones = allZones().filter((z) => !ONLY || z.id === ONLY); if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); } diff --git a/package.json b/package.json index a77cb44ca..3f7fbcfa5 100644 --- a/package.json +++ b/package.json @@ -368,7 +368,7 @@ "lists": "regenerate data/people-groups/lists/.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.", "packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.", "packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.", - "packets": "the prayer packets. READS out/posters/.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG= for one language, ONLY= for one zone, NOPHOTOS=1 to skip the photo fetch.", + "packets": "the prayer packets. READS out/posters/.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG= for one language, ONLY= for one zone, NOPHOTOS=1 to skip the photo fetch. ENV: ONLY= one zone · PACKET_LANG= · PACKET_RECORDS= a committed fixture instead of the live API · PACKET_LIST= the universal list prop for this generator (doxa default; unreached/frontier/frontier-500k/unengaged read data/people-groups/lists/.json from disk, since there is no server to fetch the asset pool from). PACKET_LIST FAILS CLOSED on an unknown or empty list rather than silently printing every group — a packet that showed the wrong list is printed and handed to someone. It exists because the browser path honours ?list= while this generator did not, so the six-row funnel (a list's own total inserted before the reader's row) was unreachable by the only pipeline that can render it.", "posters:one": "ONLY= to build one", "serve": "serves public/ the way PRODUCTION does: a DIRECTORY request returns 404, no index fallback. This was python3 -m http.server, which DID resolve a directory to index.html while the live static layer does not — so gates passed on URLs that 404 live (measured 2026-09-23; it cost a false \"publish failed\" reading and hid a bad URL in verify-shell-scroll for a whole flight). Loopback only, same as before. Override port/host with PORT/HOST.", "serve:permissive": "LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",