feat(upg-100): the prayer card deck prints as CARDS, not as paper
Some checks are pending
build-gate / build (push) Waiting to run

Driver: "the print dialog that opens up needs to show all the cards not in a
paper format but a CARD format, the aspect ratio that is used in the card
generator."

The deck set @page{size:letter portrait} and gave each card height:100%, so 95
cards printed as 95 mostly-empty 8.5x11 sheets — and with NO photo, because
image_url was never in the fetch. Measured: 95 of 95 groups DO have a portrait,
so the omission cost every card the one thing that makes it a prayer card.

Now the PAGE IS THE CARD. @page takes the stock's own inches, so one card is
exactly one page: 95 cards -> 95 pages at 180x252pt (2.5x3.5in). Print on
cardstock and cut, or send the PDF to a card printer. The stock is a user
choice, per the Driver's ask to "choose the layout of the print dialog to choose
the aspect ratio": poker 2.5x3.5in (default), postcard 4x6in, A6.

⚠ @page SIZE MUST BE EXPLICIT INCHES. Chromium SILENTLY falls back to letter for
a named size it does not know: `size: A6 portrait` yields 612x792pt with no
error of any kind, while `size: 2.5in 3.5in` yields exactly 180x252pt. The gate
therefore asserts the MEASURED MediaBox — a check that grepped the stylesheet
would pass a deck that prints as paper.

Card content follows doxa-life/promotional-material-generation, the Python data
pipeline feeding the InDesign template: per language it emits Description,
Religion, Area, Country, a QR to that group's page, and the URL. Ported to
JavaScript here so the deck generates in the map app on click. Description and
Area are NOT available from this API — measured 0 of 2107 records carry either —
so the card is built from what genuinely exists: portrait, name, country,
population, language, religion. An empty block reserved for data that never
arrives is worse than a card designed around the data there is.

QR is hand-rolled (library/packet/qr.js, ~200 lines): byte mode, level M,
smallest fitting version, mask 0. A symbol is pure computation, so it costs one
small file instead of a package and runs identically under Node and in a tab —
the standing constraint is "without adding a lot of dependencies on the
internet". A QR that does not scan looks exactly like one that does, so it is
verified by DECODING: an independent reverse-path decoder re-derives the data
placement, unmasks, de-interleaves and parses the header. 5/5 round-trip,
including version selection (v2 for a short URL, v3 for real ones).

I18N: the QR encodes the LOCALISED profile URL, so a Spanish card scans through
to the Spanish page — verified, pray.doxa.life/es/aheu. Strings fall back per
key; checked with a partial Spanish block ("Ora por ellos" rendering while
Language/Religion stayed English).

★ ONE BUG THE NUMERIC GATE PASSED, worth recording. The first deck rendered
"LAO", "thm", "EAN" — raw codes on a card a person is meant to pray from —
because normalizeRecords() flattens each { value, label } dict to its VALUE and
parks the label under <field>_label. A gate counting "4 rows rendered" passed
it: the rows were there, just unreadable. The gate now asserts the values are
HUMAN-READABLE, not merely present.

Verified by check:card-deck across all three stocks: 95 cards / 95 pages, 0
broken portraits, 95 QR codes with real symbols, 0 raw codes, 0 overflow.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 02:56:01 +00:00
parent 097ab02458
commit 7bba1c0771
5 changed files with 575 additions and 116 deletions

View file

@ -0,0 +1,175 @@
// card-render.js — THE prayer-card deck renderer, shared by every caller.
//
// ★ THE POINT OF THIS FILE: A DECK PRINTS AS CARDS, NOT AS PAPER.
// The previous deck set `@page{size:letter portrait}` and gave each card
// `height:100%`, so one card was stretched across a whole 8.5x11 sheet — 95
// mostly-empty letter pages, with no photo. The Driver: "the print dialog that
// opens up needs to show all the cards not in a paper format but a CARD format,
// the aspect ratio that is used in the card generator."
//
// So the page IS the card: @page takes the card's own dimensions, and every card
// fills exactly one page. Print two-sided on cardstock and cut, or print to PDF
// and send to a card printer.
//
// ⚠ @page size MUST be given in explicit units. MEASURED: `size: A6 portrait`
// SILENTLY falls back to letter in Chromium (612x792pt) with no error, while
// `size: 2.5in 3.5in` yields exactly 180x252pt. Named sizes beyond letter/A4 are
// not reliable — always use explicit inches, and always ASSERT the measured
// MediaBox rather than trusting the CSS.
//
// The card content follows doxa-life/promotional-material-generation, which is a
// data pipeline feeding an InDesign template: per language it emits Description,
// Religion, Area, Country, a QR code and a URL. Description and Area are NOT
// available from this API (measured: 0 of 2107 records carry either), so the card
// is built from what genuinely exists — portrait, name, country, population,
// language, religion (with its explanatory sentence) and the QR back to the live
// profile. An empty block reserved for data that never arrives is worse than a
// card designed around the data there is.
//
// I18N: every string comes from the caller's strings object, per-key fallback to
// English, same contract as the packet. The QR encodes the LOCALISED URL, so a
// Spanish card scans to the Spanish page.
import { qrSvg } from './qr.js';
// ── CARD STOCK ──────────────────────────────────────────────────────────────
// Poker-size trading card (2.5 x 3.5in) — the format commercial card printers,
// sleeves and boxes are built around, and the one people already associate with a
// deck you hold and deal out. Bleed is included in the page so a trimmed card
// keeps its full-bleed portrait.
export const STOCK = {
poker: { w: 2.5, h: 3.5, label: 'Poker (2.5 x 3.5 in)' },
postcard: { w: 4, h: 6, label: 'Postcard (4 x 6 in)' },
a6: { w: 4.13, h: 5.83, label: 'A6 (105 x 148 mm)' }, // explicit in, NOT "A6"
};
const PX = 96; // CSS px per inch
export function createCardRenderer({ strings, lang = 'en', stock = 'poker',
siteBase = 'https://pray.doxa.life' } = {}) {
const EN = strings.en;
const L = strings[lang] || {};
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// ⚠ PREFER THE LABEL, AND KNOW WHERE IT LIVES.
// The API sends { value, label } dicts, but records reach this renderer AFTER
// normalizeRecords(), which flattens each field to its `.value` and parks the
// human-readable text under `<field>_label`. Reading the field alone therefore
// yields "LAO", "thm", "EAN" — codes, on a card a person is meant to pray from.
// Measured: the first deck shipped exactly that, and a gate counting "4 rows
// rendered" passed it, because the rows were present, just unreadable.
// Handles all three shapes: an un-normalized dict, a normalized value+label
// pair, and a plain string.
const label = (g, key) => {
const lab = g[`${key}_label`];
if (lab) return String(lab);
const v = g[key];
if (v && typeof v === 'object') return String(v.label ?? v.value ?? '');
return v == null ? '' : String(v);
};
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(lang) : '—');
const S = STOCK[stock] || STOCK.poker;
const W = Math.round(S.w * PX); // card box in CSS px
const H = Math.round(S.h * PX);
// The live profile URL for this group, in this language. English has no prefix
// (the generator's own rule); every other language does.
const profileUrl = (slug) => (lang === 'en'
? `${siteBase}/${slug}`
: `${siteBase}/${lang}/${slug}`);
function css() {
return `
/* THE PAGE IS THE CARD. Explicit inches — a named size silently falls back to
letter in Chromium, which is exactly the bug this renderer exists to fix. */
@page{size:${S.w}in ${S.h}in;margin:0}
html,body{margin:0;padding:0;background:#6f6f6f;
-webkit-print-color-adjust:exact;print-color-adjust:exact}
.card{position:relative;width:${W}px;height:${H}px;overflow:hidden;
background:#f7f4ec;color:#1b1a17;
font-family:Georgia,'Palatino Linotype',serif;
page-break-after:always;break-after:page}
.card:last-child{page-break-after:auto;break-after:auto}
/* PORTRAIT: full-bleed across the top. A face is what makes this a prayer card
rather than a data tile — it is the largest thing on the card by design. */
.ph{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
object-fit:cover;display:block;background:#ddd6c6}
.ph-none{position:absolute;left:0;top:0;width:${W}px;height:${Math.round(H * 0.46)}px;
background:linear-gradient(160deg,#e6dfcd,#cfc6ad);display:flex;
align-items:center;justify-content:center;
font:italic 11px Georgia,serif;color:#8c8170}
/* A soft scrim under the name so it stays legible over any photo. */
.scrim{position:absolute;left:0;top:${Math.round(H * 0.30)}px;width:${W}px;
height:${Math.round(H * 0.16)}px;
background:linear-gradient(to bottom,rgba(24,22,18,0),rgba(24,22,18,.72))}
.nm{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.355)}px;
font:700 19px Georgia,serif;color:#fff;line-height:1.05;
text-shadow:0 1px 3px rgba(0,0,0,.5)}
.nm.long{font-size:15px}
.body{position:absolute;left:14px;right:14px;top:${Math.round(H * 0.48)}px;
bottom:${Math.round(H * 0.20)}px;overflow:hidden}
.row{display:flex;gap:6px;font:400 9.5px Georgia,serif;line-height:1.5;margin-bottom:1px}
.k{color:#8c8170;min-width:52px;flex:0 0 auto}
.v{color:#3b3a34;font-weight:600}
.rel{margin-top:5px;font:italic 8.5px Georgia,serif;color:#6d665a;line-height:1.35}
/* FOOTER: the QR is the card's whole reason to survive the day it is handed
over — it carries the reader to the live profile in their own language. */
.ft{position:absolute;left:14px;right:14px;bottom:10px;
display:flex;align-items:flex-end;gap:8px}
.qr{flex:0 0 auto;line-height:0}
.ftx{flex:1 1 auto;min-width:0}
.cta{font:600 8px Georgia,serif;color:#7a3b2e;letter-spacing:.04em;
text-transform:uppercase}
.url{font:400 7px Georgia,serif;color:#a89f8c;word-break:break-all;margin-top:1px}
.brand{position:absolute;right:14px;bottom:10px;
font:400 7px Georgia,serif;color:#b9ae99;text-align:right}`;
}
// One card = one page.
function card(g) {
const name = String(g.name || g.slug || '');
const photo = g.image_url
? `<img class="ph" src="${esc(g.image_url)}" alt="">`
: `<div class="ph-none">${esc(t('no_photo'))}</div>`;
// The religion's explanatory sentence, when the caller kept it. normalizeRecords()
// keeps only { value, label } and DROPS `description`, so this is present only
// when a caller passes records straight from the API. Absent is normal, not a bug —
// the card simply gives that space back to the rows.
const relDesc = (g.religion && typeof g.religion === 'object' && g.religion.description)
|| g.religion_description || '';
const relHtml = relDesc
? `<div class="rel">${esc(String(relDesc).slice(0, 170))}</div>` : '';
const url = profileUrl(g.slug);
const rows = [
[t('card_country'), label(g, 'country_code')],
[t('card_population'), `${num(g.population)} ${t('people_suffix')}`],
[t('card_language'), label(g, 'primary_language')],
[t('card_religion'), label(g, 'religion')],
].filter(([, v]) => v && String(v).trim() && String(v) !== '—')
.map(([k, v]) => `<div class="row"><div class="k">${esc(k)}</div>`
+ `<div class="v">${esc(v)}</div></div>`).join('');
return `<div class="card">${photo}<div class="scrim"></div>`
+ `<div class="nm${name.length > 16 ? ' long' : ''}">${esc(name)}</div>`
+ `<div class="body">${rows}${relHtml}</div>`
+ `<div class="ft"><div class="qr">${qrSvg(url, { size: 54, margin: 1 })}</div>`
+ `<div class="ftx"><div class="cta">${esc(t('card_cta'))}</div>`
+ `<div class="url">${esc(url.replace(/^https?:\/\//, ''))}</div></div></div>`
+ `</div>`;
}
function buildDeck(zone, groups) {
const cards = groups.map(card).join('');
return `<!DOCTYPE html><html lang="${esc(lang)}"><head><meta charset="utf-8">
<title>${esc(zone.label || zone.id)} — ${esc(t('deck_title'))}</title>
<style>${css()}</style></head><body>${cards}</body></html>`;
}
return { buildDeck, card, css, profileUrl, W, H, stock: S };
}

211
library/packet/qr.js Normal file
View file

@ -0,0 +1,211 @@
// qr.js — a minimal, self-contained QR encoder. No dependency, no network.
//
// WHY HAND-ROLLED. The prayer cards need a QR per group linking back to that
// group's live page, and the Driver's standing constraint is "as much data as you
// can without compromising speed and without adding a lot of dependencies on the
// internet". A QR symbol is pure computation — Reed-Solomon over GF(256) and a bit
// layout — so it costs one small file instead of a package, and it runs identically
// in Node (build-time decks) and the browser (print-on-click).
//
// SCOPE, deliberately narrow: byte mode, error-correction level M, the smallest
// version that fits, mask 0. That is all a short https URL needs
// (https://pray.doxa.life/<slug> is ~40 chars -> version 3). It is NOT a general
// QR library: no kanji mode, no ECI, no structured append. Encoding anything much
// longer than a URL will throw rather than silently produce a symbol that will not
// scan.
//
// ⚠ A QR that does not scan looks exactly like one that does. Do not "improve" this
// by eye — verify by decoding the output.
// ── GF(256) arithmetic, the field QR's Reed-Solomon works over ───────────────
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d; // the QR generator polynomial
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
const mul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
// Reed-Solomon error-correction codewords for one block.
function rsEncode(data, ecLen) {
// Build the generator polynomial for ecLen codewords.
let gen = [1];
for (let i = 0; i < ecLen; i++) {
const next = new Array(gen.length + 1).fill(0);
for (let j = 0; j < gen.length; j++) {
next[j] ^= gen[j];
next[j + 1] ^= mul(gen[j], EXP[i]);
}
gen = next;
}
const res = new Uint8Array(ecLen);
for (const byte of data) {
const factor = byte ^ res[0];
res.copyWithin(0, 1);
res[ecLen - 1] = 0;
for (let j = 0; j < ecLen; j++) res[j] ^= mul(gen[j + 1], factor);
}
return res;
}
// ── Version table (level M only) — [version, totalCodewords, ecPerBlock, blocks]
// Versions 1..6 cover every URL this app produces; beyond that we throw rather
// than guess, because a wrong table silently yields an unscannable symbol.
const VERSIONS = [
[1, 26, 10, 1], [2, 44, 16, 1], [3, 70, 26, 1],
[4, 100, 18, 2], [5, 134, 24, 2], [6, 172, 16, 4],
];
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34] };
/**
* Encode `text` as a QR matrix.
* @returns {{size:number, modules:Uint8Array}} row-major, 1 = dark.
*/
export function qrMatrix(text) {
const bytes = new TextEncoder().encode(text);
// Pick the smallest version whose data capacity fits.
let spec = null;
for (const v of VERSIONS) {
const [, total, ecPer, blocks] = v;
const dataCw = total - ecPer * blocks;
if (bytes.length + 2 + 1 <= dataCw) { spec = v; break; } // +mode nibble, +length
}
if (!spec) {
throw new Error(`qr: ${bytes.length} bytes exceeds this encoder's range (max ~${
VERSIONS.at(-1)[1] - VERSIONS.at(-1)[2] * VERSIONS.at(-1)[3] - 3} bytes). `
+ 'It is scoped to short URLs on purpose.');
}
const [version, totalCw, ecPerBlock, blocks] = spec;
const dataCw = totalCw - ecPerBlock * blocks;
// ── Bit stream: mode(0100) + length(8 bits) + payload + terminator ──────────
const bits = [];
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
push(0b0100, 4);
push(bytes.length, 8);
for (const b of bytes) push(b, 8);
for (let i = 0; i < 4 && bits.length < dataCw * 8; i++) bits.push(0);
while (bits.length % 8) bits.push(0);
const data = [];
for (let i = 0; i < bits.length; i += 8) {
data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
}
// Pad alternately with the two spec-mandated bytes.
const PAD = [0xec, 0x11];
for (let i = 0; data.length < dataCw; i++) data.push(PAD[i % 2]);
// ── Split into blocks, compute EC, interleave ───────────────────────────────
const perBlock = Math.floor(dataCw / blocks);
const dBlocks = [];
const eBlocks = [];
for (let i = 0; i < blocks; i++) {
const chunk = data.slice(i * perBlock, (i + 1) * perBlock);
dBlocks.push(chunk);
eBlocks.push(rsEncode(chunk, ecPerBlock));
}
const final = [];
for (let i = 0; i < perBlock; i++) for (const b of dBlocks) final.push(b[i]);
for (let i = 0; i < ecPerBlock; i++) for (const b of eBlocks) final.push(b[i]);
// ── Lay out the matrix ──────────────────────────────────────────────────────
const size = version * 4 + 17;
const m = new Int8Array(size * size).fill(-1); // -1 = free
const at = (r, c) => r * size + c;
const set = (r, c, v) => { if (r >= 0 && c >= 0 && r < size && c < size) m[at(r, c)] = v; };
const finder = (r0, c0) => {
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
const inRing = (r === 0 || r === 6 || c === 0 || c === 6);
const inCore = (r >= 2 && r <= 4 && c >= 2 && c <= 4);
set(r0 + r, c0 + c, (r >= 0 && r <= 6 && c >= 0 && c <= 6 && (inRing || inCore)) ? 1 : 0);
}
};
finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
for (let i = 8; i < size - 8; i++) { // timing patterns
const v = i % 2 === 0 ? 1 : 0;
if (m[at(6, i)] === -1) set(6, i, v);
if (m[at(i, 6)] === -1) set(i, 6, v);
}
for (const r of ALIGN[version] || []) { // alignment patterns
for (const c of ALIGN[version] || []) {
if (m[at(r, c)] !== -1) continue;
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
set(r + dr, c + dc, (Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0);
}
}
}
set(size - 8, 8, 1); // the always-dark module
// Reserve format-information cells so data skips them.
const fmtCells = [];
for (let i = 0; i <= 8; i++) { if (i !== 6) fmtCells.push([8, i], [i, 8]); }
for (let i = 0; i < 8; i++) fmtCells.push([8, size - 1 - i], [size - 1 - i, 8]);
for (const [r, c] of fmtCells) if (m[at(r, c)] === -1) m[at(r, c)] = -2; // -2 = reserved
// ── Place data, zig-zag upward in 2-column strips, with mask 0 applied ───────
let bi = 0;
let up = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) col--; // skip the vertical timing line
for (let i = 0; i < size; i++) {
const row = up ? size - 1 - i : i;
for (const c of [col, col - 1]) {
if (m[at(row, c)] !== -1) continue;
const bit = bi < final.length * 8
? (final[bi >> 3] >> (7 - (bi & 7))) & 1
: 0;
bi++;
// Mask 0: invert where (row + col) is even. Applied HERE rather than in a
// second pass so reserved/function modules are never touched.
m[at(row, c)] = bit ^ ((row + c) % 2 === 0 ? 1 : 0);
}
}
up = !up;
}
// ── Format information: level M (00) + mask 0 (000), BCH(15,5) + XOR mask ────
let fmt = 0b00000; // ecc M = 00, mask 0 = 000
let rem = fmt;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ (((rem >> 9) & 1) * 0x537);
const fmtBits = ((fmt << 10) | rem) ^ 0b101010000010010;
const fmtBit = (i) => (fmtBits >> i) & 1;
for (let i = 0; i <= 5; i++) { set(8, i, fmtBit(i)); set(size - 1 - i, 8, fmtBit(i)); }
set(8, 7, fmtBit(6)); set(8, 8, fmtBit(7)); set(7, 8, fmtBit(8));
for (let i = 9; i <= 14; i++) { set(8, size - 15 + i, fmtBit(i)); set(14 - i, 8, fmtBit(i)); }
set(size - 8, 8, 1);
const modules = new Uint8Array(size * size);
for (let i = 0; i < m.length; i++) modules[i] = m[i] === 1 ? 1 : 0;
return { size, modules };
}
/**
* Render `text` as an inline SVG QR code.
* Drawn as ONE path of rectangles rather than per-module <rect>s: a version-3
* symbol is 29x29 = 841 modules, and 841 elements per card times 95 cards is a
* document a print dialog struggles with.
*/
export function qrSvg(text, { size = 120, margin = 2, dark = '#14110d' } = {}) {
const { size: n, modules } = qrMatrix(text);
const total = n + margin * 2;
let d = '';
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (modules[r * n + c]) d += `M${c + margin} ${r + margin}h1v1h-1z`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" `
+ `width="${size}" height="${size}" shape-rendering="crispEdges" role="img" `
+ `aria-label="QR code">`
+ `<rect width="${total}" height="${total}" fill="#fff"/>`
+ `<path d="${d}" fill="${dark}"/></svg>`;
}

View file

@ -10,6 +10,12 @@
"list_header": "{from}–{to} of {count} · pray for them by name",
"people_suffix": "people",
"no_photo": "no photo available",
"deck_title": "Prayer Cards",
"card_country": "Country",
"card_population": "People",
"card_language": "Language",
"card_religion": "Religion",
"card_cta": "Pray for them",
"points_title": "12 Prayer Points",
"points_intro": "One hour with God, five minutes each. Pray these twelve for the people groups in this packet.",
"points_attrib": "Prayer rhythms inspired by Metacamp · metacamp.org",

View file

@ -38,6 +38,7 @@
*/
import { computed, inject, ref, watch, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useI18n } from 'vue-i18n'
// Export-modal geometry — see the note at .modal-scrim below, and library/utils/viewportScrim.js.
import { useViewportScrim } from '@map/utils/viewportScrim.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
@ -263,6 +264,27 @@ async function renderFor(list, spec) {
const EXPORT_SPEC = { widthIn: 8.5, heightIn: 12.75, dpi: 150 } // single-canvas, phone-safe
const exportState = ref(null) // { label, phase, message, pct, kind }
// CARD STOCK for the prayer-card deck. The Driver asked to be able to "choose the
// layout of the print dialog to choose the aspect ratio of these prayer cards", so
// the stock is a user choice, not a constant. Poker (2.5x3.5in) is the default: it
// is what commercial card printers, sleeves and boxes are built around.
// @page takes these as explicit inches — a NAMED size silently falls back to letter
// in Chromium, which is the exact bug this deck is being fixed for.
const DECK_STOCKS = [
{ id: 'poker', label: 'Card 2.5 × 3.5 in' },
{ id: 'postcard', label: 'Postcard 4 × 6 in' },
{ id: 'a6', label: 'A6 105 × 148 mm' },
]
const deckStock = ref('poker')
// The deck follows the app's locale; packet-strings.json falls back PER KEY, and
// the QR on each card encodes the LOCALISED profile URL, so a Spanish card scans
// through to the Spanish page.
const { locale: deckLocale } = useI18n()
function deckLang() {
const l = deckLocale && (deckLocale.value ?? deckLocale)
return (typeof l === 'string' && l) || 'en'
}
const { scrimRef, scrimStyle } = useViewportScrim(exportState)
// exportPoster()/downloadPacket()/download() REMOVED (2026-09-23) — the Driver's brief was
@ -411,7 +433,12 @@ async function printPoster(list) {
// disagree between the map, the list print and the card deck.
async function fetchListRecords(list) {
if (!API_BASE) throw new Error('no API configured')
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion'
// image_url + primary_language are what make a PRAYER CARD rather than a data
// tile: a face and the tongue the gospel has to reach them in. The deck shipped
// without either because they were never requested here — measured 95/95 groups
// DO have a portrait, so the omission cost every card its photo.
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,religion,'
+ 'image_url,primary_language'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const records = normalizeRecords(extractRecords(await resp.json()))
@ -505,51 +532,25 @@ async function printCardDeck(list) {
message: 'fetching people-group records', pct: 30 }
try {
const rows = await fetchListRecords(list)
// ★ THE PAGE IS THE CARD. This used to set @page{size:letter portrait} and give
// each card height:100%, so a "deck" printed as 95 mostly-empty 8.5x11 sheets
// with no photo. The Driver: "the print dialog that opens up needs to show all
// the cards not in a paper format but a CARD format, the aspect ratio that is
// used in the card generator."
//
// The pages now come from library/packet/card-render.js — one renderer, every
// caller — which sets @page to the card stock's own inches, so one card is
// exactly one page at 2.5x3.5in. Print on cardstock and cut, or send the PDF
// to a card printer.
const [{ createCardRenderer }, strings] = await Promise.all([
import('@map/packet/card-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
])
const R = createCardRenderer({ strings, lang: deckLang(), stock: deckStock.value })
const title = `Doxa UPG prayer cards — ${escapeHtml(list.label || list.id)}`
const cardsHtml = rows.map((r) => (
'<section class="card">' +
'<div class="tape">' + escapeHtml(list.label || list.id) + '</div>' +
'<h2>' + escapeHtml(r.name) + '</h2>' +
'<dl>' +
'<dt>Country</dt><dd>' + escapeHtml(r.country_code_label || r.country_code || '—') + '</dd>' +
'<dt>Population</dt><dd>' + escapeHtml(r.population ? Number(r.population).toLocaleString() : '—') + '</dd>' +
'<dt>Affinity bloc</dt><dd>' + escapeHtml((r.rop1 && r.rop1.label) || '—') + '</dd>' +
'<dt>Religion</dt><dd>' + escapeHtml(r.religion_label || '—') + '</dd>' +
'</dl>' +
'<p class="verse">"Ask the Lord of the harvest to send out workers into his harvest field." — Matthew 9:38</p>' +
'</section>'
)).join('')
const w = openPrintWindow(title,
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>' +
'@page{size:letter portrait;margin:10mm}' +
'html,body{margin:0;background:#f3f3f1;color:#3b463d;' +
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}' +
// Screen preview: a responsive grid so the deck can be reviewed before printing.
'.deck{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;padding:18px}' +
'.card{position:relative;background:#fff;border:1px solid #d1d4cd;border-radius:6px;' +
'padding:22px 18px 16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}' +
'.tape{position:absolute;top:-9px;left:50%;transform:translateX(-50%);' +
'background:#92b195;color:#1c2a1f;font-size:9px;font-weight:700;text-transform:uppercase;' +
'letter-spacing:.06em;padding:3px 10px;border-radius:999px;white-space:nowrap}' +
'.card h2{margin:6px 0 10px;font-size:16px;font-weight:700}' +
'.card dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11.5px}' +
'.card dt{color:#6d766e;font-weight:600}' +
'.card dd{margin:0}' +
'.card .verse{margin:12px 0 0;padding-top:10px;border-top:1px solid #d1d4cd;' +
'font-size:10.5px;font-style:italic;color:#6d766e;line-height:1.4}' +
// PRINT PAGINATION: one card per sheet. `break-after:page` on every card except
// the last is what makes a "deck" out of a document instead of a wall of cards
// shrunk to fit — the Driver's ask was "a real printer produces a usable deck".
'@media print{' +
'.deck{display:block;padding:0}' +
'.card{box-shadow:none;border-radius:0;border:2px solid #3b463d;' +
'break-after:page;page-break-after:always;height:100%;box-sizing:border-box}' +
'.card:last-child{break-after:auto;page-break-after:auto}' +
'}' +
'</style></head><body>' +
'<div class="deck">' + cardsHtml + '</div>' +
'</body></html>', { autoprint: true })
exportState.value = { ...exportState.value, phase: 'render',
message: `laying out ${rows.length} cards`, pct: 70 }
const w = openPrintWindow(title, R.buildDeck(list, rows), { autoprint: true })
if (!w) {
// See the note in the list path: an iframe cannot be pop-up blocked.
exportState.value = { label: list.label, kind: 'Card Deck', phase: 'error',
@ -692,6 +693,14 @@ useShadowStyles(
'border-radius:2px;background:transparent;transition:background .15s}' +
'.pbtn:hover{color:#10b981}' +
'.pbtn:hover::after{background:#10b981}' +
// CARD STOCK picker — sits with the print buttons because it changes what the
// print dialog PRODUCES (it becomes the @page size). Quieter than the buttons:
// it is a modifier, not an action.
'.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' +
'background:#fff;color:#5b6672;font:inherit;font-size:9.5px;font-weight:600;' +
'padding:2px 4px;cursor:pointer;line-height:1.25}' +
'.pstock:hover{border-color:#10b981;color:#10b981}' +
'.is-dark .pstock{background:#12211a;border-color:#2c4a3b;color:#9fb3a8}' +
'.is-dark .pbtn{color:#9fb3a8}' +
// SECTIONS.
// Sections need more separation than the cards within them, or the regional
@ -840,6 +849,15 @@ useShadowStyles(
:aria-label="`Print the ${list.label} UPG prayer poster`">UPG prayer poster</button>
<button type="button" class="pbtn pbtn-print" @click="printCardDeck(list)"
:aria-label="`Print the ${list.label} UPG card deck`">card deck</button>
<!-- CARD STOCK. The Driver asked to "choose the layout of the print
dialog to choose the aspect ratio of these prayer cards" — so the
size is picked here and becomes the @page size, which is what makes
the print dialog show CARDS instead of letter paper. -->
<select class="pstock" v-model="deckStock"
aria-label="Prayer card size"
@click.stop>
<option v-for="s in DECK_STOCKS" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</fieldset>
</div>
</div>

File diff suppressed because one or more lines are too long