Prayer List gets the cards' caret, and starts printing the packet

The Driver asked for the SAME caret the cards already have on the Prayer
List, with two entries -- Full detail and Lightweight -- and said plainly
not to destroy the print options that exist. So nothing is merged, nothing
is removed, and the Poster is untouched.

There was no dropdown COMPONENT to reuse: the cards' caret is an inline
disclosure, one ref plus a sibling tape of plain buttons, deliberately not
a <select> because a native dropdown collides with other dropdowns on the
host page. So the PATTERN is generalized rather than copied. deckOpenFor
now holds a compound `${id}:${which}` key, which keeps "opening one closes
the others" free -- copy/paste would have given two open-states, and two
tapes could then sit open at once and overlap in the grid gap.

While wiring it, a real defect surfaced: THE WALL'S PRAYER LIST BUTTON HAS
NEVER PRINTED A PACKET. downloadPacket calls printList, a columnar table of
name/country/population/religion. The packet -- map, life calendar,
%-evangelical grid, triangle, profiles, wheel, points -- is printPacket,
which every other surface calls and this wall never did. That is a direct
cause of the packet "not following the design that was asked for" here: no
change to the renderer could reach a button that was not calling it.
printList stays exported for any surface that still wants the list.

The two packets are a FILTER over the existing page array, not a second
code path, so they cannot drift apart in layout, i18n, RTL or print scale.
The filter is keyed and never indexed -- an index filter reads identically
today and drops the wrong page the first time the order changes, and that
order is the Driver's. listPages is deliberately outside the filter: a
variant may drop a FIXED page, never one whose count depends on the data,
because that drops people silently. An unknown variant falls back to full.

internal/check-packet-variants.mjs pins all of it, and was mutation-tested
rather than trusted: dropping list pages, reverting to printList, adding a
second open-state ref, and typoing a variant id each fail with their own
message.
This commit is contained in:
Doxa Maps 2026-09-28 15:48:23 +00:00
parent 009d73b44e
commit 642fc15310
5 changed files with 246 additions and 16 deletions

View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-packet-variants — the two packets behind the Prayer List caret, and the promise that adding
* that caret did not disturb the Cards.
*
* WHY A GATE: the Driver asked for a second print option on the Prayer List and said, in the same
* breath, "I don't want you to destroy [the print options]". Those are two assertions, and only one
* of them is about the new code. The other — THE CARDS STILL WORK EXACTLY AS THEY DID — is the kind
* of promise that is true when you make it and quietly false three commits later, because nothing
* fails when a shared disclosure grows a second caller. So it is pinned here.
*
* Every check reads the REAL source; none reads a fixture. Headless, no browser, no network.
* Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
const WALL = read('library/components/poster/PosterWall.vue')
const PRINTS = read('library/composables/usePosterPrints.js')
const EN = JSON.parse(read('library/i18n/locales/en/buttons.json'))
const fails = []
const ok = (cond, msg) => { if (!cond) fails.push(msg) }
// ── 1. THE TWO VARIANTS EXIST AND AGREE ACROSS THE SEAM ────────────────────────────────────────
// packet-render owns the variant names; PosterWall names them again in its menu. A typo in either
// is invisible: buildPacket falls back to 'full' on an unknown variant, so "Lightweight" would
// print the FULL packet — a wrong packet, not an error. Both sides are read and compared.
const { createPacketRenderer } = await import(resolve(ROOT, 'library/packet/packet-render.js'))
const strings = JSON.parse(read('library/scripts/posters/packet-strings.json'))
const R = createPacketRenderer({ strings, lang: 'en' })
ok(R.VARIANTS && typeof R.VARIANTS === 'object', 'packet-render does not export VARIANTS')
const renderIds = Object.keys(R.VARIANTS || {})
ok(renderIds.includes('full') && renderIds.includes('lightweight'),
`VARIANTS must define full + lightweight, got: ${renderIds.join(', ')}`)
// The UI's list, read out of the source rather than duplicated here — duplicating it would make
// this gate agree with itself instead of with the component.
const uiIds = [...WALL.matchAll(/\{\s*id:\s*'([a-z]+)',\s*labelKey:\s*'([\w.]+)'\s*\}/g)]
ok(uiIds.length >= 2, `the wall's packetVariants list was not found (got ${uiIds.length} entries)`)
for (const [, id, key] of uiIds) {
ok(renderIds.includes(id), `the wall offers variant '${id}' but packet-render has no such VARIANT — buildPacket would silently fall back to 'full' and print the WRONG packet`)
// A missing label key renders the raw key as the button text ("buttons.packetFull"), which looks
// like a broken translation rather than a missing string.
const leaf = key.replace(/^buttons\./, '')
ok(EN[leaf] !== undefined, `label key '${key}' is absent from library/i18n/locales/en/buttons.json — the button would print the raw key`)
}
ok(EN.packetVariantAria !== undefined, 'buttons.packetVariantAria is missing — the revealed row would have no accessible name')
// ── 2. THE TWO PACKETS DIFFER IN THE SPECIFIED WAY, AND LOSE NOBODY ────────────────────────────
// ★ THE LOAD-BEARING ONE. A variant drops FIXED pages; it must never drop a page whose count
// depends on the data, because that drops PEOPLE — silently, with the packet still looking like a
// packet. Asserted by counting the group names actually printed, not the sheets.
const groups = Array.from({ length: 45 }, (_, i) => ({ slug: `g${i}`, name: `Group ${i}`, country_code: 'MYS', population: 1000, imb_gsec: 1 }))
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(), '<svg/>', funnel, christian, v)
const count = (html, re) => (html.match(re) || []).length
const full = build('full')
const light = build('lightweight')
const names = (h) => count(h, /Group \d+</g)
ok(names(full) === 45, `full packet printed ${names(full)} of 45 groups`)
ok(names(light) === 45, `LIGHTWEIGHT printed ${names(light)} of 45 groups — a variant dropped PEOPLE, which is the one thing it may never do`)
// The life calendar and the %-evangelical grid are the two contemplative sheets lightweight drops.
ok(count(full, /sheet-life/g) >= 1, 'the full packet has no life calendar')
ok(count(full, /sheet-christian/g) >= 1, 'the full packet has no %-evangelical page')
ok(count(light, /sheet-life/g) === 0, 'lightweight still carries the life calendar')
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)}`)
// An unknown variant must print the FULL packet. A typo that printed a SHORTER packet would look
// like a successful print.
ok(build('nonsense-typo') === full, 'an unknown variant did not fall back to the full packet')
// ── 3. THE PRAYER LIST BUTTON PRINTS THE PACKET, NOT THE COLUMNAR LIST ─────────────────────────
// ★ THE DEFECT THIS WORK FOUND. The wall's Prayer List button called prints.printList() — a table
// of name/country/population/religion — while every other surface called printPacket(). So the
// packet the Driver specified was never what this button produced. Pinned so it cannot regress.
ok(/printPacketAs[\s\S]{0,600}prints\.printPacket\(/.test(WALL),
'printPacketAs does not call prints.printPacket — the Prayer List caret would print the columnar LIST again, not the packet')
ok(/variant/.test(PRINTS.match(/async function printPacket\([^)]*\)/)?.[0] || ''),
'usePosterPrints.printPacket does not accept a variant — the caret\'s choice would be silently dropped')
// printList itself STAYS (dx-shepherd: don't delete it, just stop the button calling it).
ok(/function printList\(/.test(PRINTS) && /printList,/.test(PRINTS),
'printList was removed — it must remain exported for any surface that still wants the columnar list')
// ── 4. THE CARDS ARE UNTOUCHED ─────────────────────────────────────────────────────────────────
// The Driver: the Cards and their caret dropdown stay EXACTLY as they are. The only sanctioned
// change is the comparison key. Everything else about that button is asserted byte-for-byte.
const deckBtn = WALL.match(/<button v-if="canPrintRows" type="button" class="pbtn pbtn-deck"[\s\S]*?<\/button>/)
ok(!!deckBtn, 'the Card Deck button was not found at all')
if (deckBtn) {
const b = deckBtn[0]
ok(/class="pbtn pbtn-deck"/.test(b), 'the Card Deck button lost its classes')
ok(/t\('buttons\.cardDeck'\)/.test(b), 'the Card Deck button lost its label key')
ok(/@click\.stop="toggleDeck\(item\.id, \$event, 'deck'\)"/.test(b), 'the Card Deck button no longer opens its own row')
ok(/:aria-expanded=/.test(b), 'the Card Deck button lost aria-expanded')
}
// The size row still gates on canPrintRows AND stockOptions.length — dropping either would render
// an empty tape.
ok(/v-if="canPrintRows && stockOptions\.length && deckOpenFor === deckKey\(item\.id, 'deck'\)"/.test(WALL),
'the card-size row lost one of its guards, so it could render as an empty tape')
// printDeckAt still sets the stock and prints the deck.
ok(/function printDeckAt[\s\S]{0,300}pickedStock\.value = stockId[\s\S]{0,200}prints\.printCardDeck\(item\)/.test(WALL),
'printDeckAt no longer sets the picked stock before printing the deck')
// ── 5. EXACTLY ONE TAPE OPEN AT A TIME ─────────────────────────────────────────────────────────
// ★ The property copy/paste would have lost. ONE ref holding ONE key is what makes "opening one
// closes the others" free; two booleans would let both tapes sit open and overlap in the grid gap.
const refs = [...WALL.matchAll(/const (deckOpenFor|packetOpenFor|listOpenFor)\s*=\s*ref\(/g)]
ok(refs.length === 1 && refs[0][1] === 'deckOpenFor',
`expected exactly ONE disclosure ref, found ${refs.length}: ${refs.map((m) => m[1]).join(', ')} — two open-states means two tapes can be open at once`)
// Both tapes compare against that single ref, via the compound key.
ok(/deckOpenFor === deckKey\(item\.id, 'packet'\)/.test(WALL), 'the packet tape does not read the shared disclosure ref')
ok(/deckOpenFor === deckKey\(item\.id, 'deck'\)/.test(WALL), 'the card tape does not read the shared disclosure ref')
// The key must include BOTH the id and the which — an id-only key would make the two buttons on the
// SAME card toggle each other's row.
ok(/const deckKey = \(id, which\) => `\$\{id\}:\$\{which\}`/.test(WALL),
'deckKey does not combine the item id AND the button — the two buttons on one card would fight over the same key')
if (fails.length) {
console.error('PACKET-VARIANTS: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('PACKET-VARIANTS: PASS — both packets print every group, lightweight drops exactly the two contemplative sheets, an unknown variant falls back to full, the Prayer List prints the PACKET and not the columnar list, the Cards button and its size row are unchanged, and one ref means one open tape.')

View file

@ -461,9 +461,19 @@ const previewIsControl = computed(() => props.previewAction === 'print')
// rather than a boolean-per-card means opening one CLOSES the others for free:
// two size rows open at once would overlap in the grid gap, and there is no
// reading of the Driver's request where two are wanted.
// ★ NOW A COMPOUND KEY, `${id}:${which}` — the Driver, 2026-09-28, asked for the SAME caret on the
// Prayer List with two entries (Full detail / Lightweight), and explicitly not a merged menu and not
// a second dropdown component. There is no dropdown COMPONENT here to reuse: this is an inline
// disclosure, one ref plus a sibling tape of plain buttons. So the PATTERN is generalized instead of
// copied — copy/paste would give two independent open-states, and then opening the Prayer List would
// NOT close the Cards, so two tapes could sit open at once and overlap in the grid gap. That is a
// visible regression on the Cards row the Driver said to leave exactly as it is.
// Holding ONE key keeps "opening one closes the others" free, which is why it was an id to begin with.
const deckOpenFor = ref(null)
function toggleDeck(id, ev) {
deckOpenFor.value = deckOpenFor.value === id ? null : id
const deckKey = (id, which) => `${id}:${which}`
function toggleDeck(id, ev, which = 'deck') {
const key = deckKey(id, which)
deckOpenFor.value = deckOpenFor.value === key ? null : key
// Tell the size row how far down to start: the print row's REAL height, which is
// one line in en and two in the long-label locales. Walking up from the clicked
// button to its own .pfs keeps this correct per card rather than per wall.
@ -493,6 +503,39 @@ function downloadPacket(item) {
prints.printList(item)
}
// ★ THE TWO PACKETS the Prayer List caret reveals. Declared here rather than inline in the
// template so the row is DATA — a third packet is one entry, not a markup change — and so the
// gate can assert the entries without parsing HTML.
// ⚠ The ids MUST match packet-render's VARIANTS keys; buildPacket falls back to 'full' on an
// unknown one, which would silently print the wrong packet rather than failing. Asserted in
// internal/check-packet-variants.mjs, which reads BOTH sides and compares.
const packetVariants = [
{ id: 'full', labelKey: 'buttons.packetFull' },
{ id: 'lightweight', labelKey: 'buttons.packetLightweight' },
]
// Choosing a variant IS the print command, exactly as choosing a card size is — the reader
// already said "prayer list" by opening the row. Routed through downloadPacket's guard rather
// than around it, so a wall with no records provider answers "not yet" identically whichever
// entry is picked. Opening a variant closes the row, like printDeckAt.
// ★ THIS CALLS printPacket, NOT printList — and that difference is a DEFECT FIX, not a refactor.
// MEASURED: downloadPacket() above calls prints.printList(item), which renders a COLUMNAR TABLE
// (name / country / population / religion — usePosterPrints.js:150). The PRAYER PACKET the Driver
// specified — map, life calendar, %-evangelical grid, triangle, profiles, wheel, points — is
// prints.printPacket, and this wall never called it. Every OTHER surface does
// (upg-100-list.vue:1430 and :1671, research-map.vue:1597, countries-map.vue:1360); the poster wall
// was the sole exception. So the button labelled "Prayer List" here has been printing a list, which
// is why the packet "does not follow the design that was asked for" ON THIS SURFACE — it was never
// the packet renderer's output.
function printPacketAs(item, variantId) {
if (!canPrintRows.value) { downloadPacket(item); return }
deckOpenFor.value = null
// mapSvg/funnel are optional: printPacket defaults them, and mapPage/funnelPage each render ''
// without data rather than breaking, so the wall prints a correct packet with the pages it can
// truthfully draw. A host that has them can pass them through the same seam.
prints.printPacket(item, { variant: variantId })
}
// ONE delivery point for the finished poster, so 'download' and 'print' cannot
// drift apart or be applied to only one of the two paths (shipped .webp vs
// browser-rasterised). Both callers in exportPoster() route through here.
@ -1061,8 +1104,16 @@ useShadowStyles(
starts exactly on the border it is taping over. -->
<fieldset class="tape pfs">
<legend>{{ t('buttons.printOptions') }}</legend>
<!-- ★ PRAYER LIST IS NOW A DISCLOSURE TOO (Driver 2026-09-28), using the SAME
mechanism as Card Deck below — same toggleDeck, same one-open-at-a-time key,
same revealed .tape of plain buttons. NOT a <select> and NOT a new component,
for the reason already given at the Card Deck comment: a native dropdown
collides with other dropdowns on the host page.
It reveals the two PACKETS — Full detail and Lightweight. Clicking the button
no longer prints immediately, because there are now two things it could mean. -->
<button v-if="!(hidePrayerListWhenSoon && !canPrintRows)" type="button" class="pbtn"
@click="downloadPacket(item)">{{ t('buttons.prayerList') }}</button>
:aria-expanded="String(deckOpenFor === deckKey(item.id, 'packet'))"
@click.stop="toggleDeck(item.id, $event, 'packet')">{{ t('buttons.prayerList') }}</button>
<button type="button" class="pbtn" @click="exportPoster(item)">{{ t('buttons.posterMap') }}</button>
<!-- ★ CARD DECK IS NOT A PEER OPTION, AND ITS SIZE PICKER IS HIDDEN UNTIL ASKED
FOR (Driver 2026-09-25): "card should not be second option, and I don't want
@ -1075,14 +1126,24 @@ useShadowStyles(
A 162px <select> was the single widest control in a 172px row — removing it
from the default row is most of why the row now fits on one line. -->
<button v-if="canPrintRows" type="button" class="pbtn pbtn-deck"
:aria-expanded="String(deckOpenFor === item.id)"
@click.stop="toggleDeck(item.id, $event)">{{ t('buttons.cardDeck') }}</button>
:aria-expanded="String(deckOpenFor === deckKey(item.id, 'deck'))"
@click.stop="toggleDeck(item.id, $event, 'deck')">{{ t('buttons.cardDeck') }}</button>
</fieldset>
<!-- The revealed PACKET row — deliberately the same markup shape and the same .tape /
.pstock classes as the card-size row below, because to the reader these are the
same gesture. Two entries, both shipped together: a one-item dropdown would be a
caret that explains nothing. -->
<div v-if="deckOpenFor === deckKey(item.id, 'packet')"
class="tape pstocks" role="group" :aria-label="t('buttons.packetVariantAria')">
<button v-for="v in packetVariants" :key="v.id" type="button" class="pstock"
@click.stop="printPacketAs(item, v.id)">{{ t(v.labelKey) }}</button>
</div>
<!-- The revealed size row. Rendered as SIBLINGS in a second tape rather than a
<select> so there is no native dropdown to collide with other dropdowns —
which is the constraint the Driver gave, not a styling preference. -->
<div v-if="canPrintRows && stockOptions.length && deckOpenFor === item.id"
<div v-if="canPrintRows && stockOptions.length && deckOpenFor === deckKey(item.id, 'deck')"
class="tape pstocks" role="group" :aria-label="t('buttons.cardStockAria')">
<button v-for="s in stockOptions" :key="s.id" type="button" class="pstock"
:class="{ 'is-on': pickedStock === s.id }"

View file

@ -227,7 +227,9 @@ export function usePosterPrints(opts = {}) {
// `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 } = {}) {
// `variant` selects WHICH packet ('full' | 'lightweight' — packet-render's VARIANTS). It defaults
// to 'full', so every existing caller keeps printing exactly what it prints today.
async function printPacket(item, { mapSvg = '', funnel = null, variant = 'full' } = {}) {
if (busy()) return;
const kind = 'Prayer Packet';
set(item, kind, 'fetch', 'fetching people groups', 30);
@ -250,7 +252,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, funnel, christian), { autoprint: true });
openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian, variant), { autoprint: true });
done(item, kind);
} catch (e) { fail(item, kind, e); }
}

View file

@ -38,6 +38,9 @@
"close": "Close",
"showOnMapAria": "Show {label} on the map",
"cardStockAria": "Prayer card size",
"packetVariantAria": "Prayer packet detail",
"packetFull": "Full detail",
"packetLightweight": "Lightweight",
"previewPrintPoster": "Preview & print the {label} poster",
"clearSelection": "Clear selection",
"noOptions": "No options found",

View file

@ -727,7 +727,26 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page
// that cannot state true numbers must not appear at all — better a 4-section packet than one
// asserting figures it could not read.
function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null) {
// ★ TWO PACKETS, ONE RENDERER — the Driver's §A, 2026-09-28: the Prayer List print gains a caret
// with "Full detail" and "Lightweight". They are NOT two code paths. Every page below already
// exists and is already exported; a variant is a FILTER over the page array, so the two packets
// cannot drift apart in layout, i18n, RTL or print scale — there is only ever one implementation
// of each page.
//
// LIGHTWEIGHT drops the two pages that are CONTEMPLATIVE rather than informational — the life
// calendar and the evangelical-share grid. That is the whole difference, and it is deliberate:
// those two are a full sheet each and exist to make the reader FEEL a number, which is exactly
// what you cut when you want a packet to carry rather than to sit with.
//
// ⚠ 'lightweight' NEVER drops listPages. Losing a list page loses PEOPLE, silently — the failure
// check-packet-render's sheet assertion exists to catch. A variant may drop a FIXED page; it may
// 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
};
function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null, variant = 'full') {
// ★ 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
@ -737,14 +756,22 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// then the 12 prayer points and the prayer wheel
// Pages 2 and 3 are deliberately adjacent: the reader learns to read 5,200 boxes as their own
// life, and then sees the same 5,200 boxes answer "how many of these people know Christ".
// ⚠ KEYED, NOT INDEXED. The omit-set names pages; it never carries positions. An index-based
// filter reads identically today and silently drops the WRONG page the first time this order
// changes — and the order above is the Driver's, so it is expected to change again.
// An unknown variant falls back to the full packet: a typo must not silently print a shorter
// packet, because a packet missing two pages still looks like a packet.
const omit = VARIANTS[variant] || VARIANTS.full;
const keep = (key) => !(omit && omit.has(key));
const pages = [
mapPage(zone, groups, mapSvg),
lifePage(zone),
christianPage(zone, christian),
funnelPage(zone, groups, funnel),
keep('map') && mapPage(zone, groups, mapSvg),
keep('life') && lifePage(zone),
keep('christian') && christianPage(zone, christian),
keep('funnel') && funnelPage(zone, groups, funnel),
// NOT behind keep() — see the VARIANTS comment: dropping list pages drops people.
...listPages(zone, groups, photos),
pointsPage(),
wheelPage(),
keep('points') && pointsPage(),
keep('wheel') && wheelPage(),
].filter(Boolean);
// ⚠ dir MUST be set for right-to-left languages or the packet renders Arabic
// left-aligned — readable glyphs in the wrong place, which looks like a
@ -756,5 +783,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
<body>${pages.join('\n')}</body></html>`;
}
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
// VARIANTS is exported so the UI enumerates the packets it can print instead of hardcoding two
// strings in a menu — add a third variant here and the caret gains an entry with no UI change.
return { buildPacket, mapPage, lifePage, christianPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H, VARIANTS };
}