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.
175 lines
9.1 KiB
JavaScript
175 lines
9.1 KiB
JavaScript
// 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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
// ⚠ 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 };
|
|
}
|