CARD-SPEC — the printed prayer card
Status: BUILT, RENDERED and TESTED. Shaw's directive, 2026-09-28. Supersedes the 2026-09-25
branded-card version in full.
Renderer: prayer-engine/packages/card/ — layout.json (the geometry), src/render.ts (the
renderer), scripts/build-assets.py (the built assets)
Consumed by: PROMPT-PACKS.md §4 and §7 · REALISM-RULES.md R9 · the st-peters pass-2 pack ·
apps/worker/src/handlers.ts
What Shaw asked for, in his words
"I need you to refactor the whole process here instead of handwritten, because I don't think handwritten matters. It actually causes a little bit of grief for me. I need to refactor this whole offer to remove the whole handwritten card. It's going to be a printed card, so first off, we need to readjust the whole process of the card image. The card image needs to be the same card. The size of the card needs to be exact and the exact same size each time, same length and width, whatever it is, and the text is always printed. It's always going to be uniform, with a character limit. The reason we're going to do it this way is because we can generate the card first, then we can superimpose the card through an LLM process with the hands and the background and whatever it is. It's always going to be uniform in every way. This also unlocks the fact that we can actually send the card to the person as an FCL, like get the card in the mail. We can get it printed and sent out through a print service."
That is the whole design, and the last clause is the one that changes the shape of the pipeline: the card is a print, so it must exist as a file before any image model touches it.
The three defects this closes
| Defect | Caught | Why printing fixes it |
|---|---|---|
| The bump wrote the same prayer in a different hand — "it looks like the person wrote two different cards" | 2026-09-21 | One rendered PNG, attached at both locations. The pair cannot disagree about the card. |
| Three renders drew three different hands — "some of the images look like its different hands" | 2026-09-25 | The model no longer draws a hand writing; it holds a card that already exists. |
| A mis-transcribed name dead-lettered a paid order | 2026-09-25 | Nothing on the card is transcribed from a model any more. The QA gate grades a print against the string it was printed from. |
And two things it unlocks that no amount of prompt work could:
- A physical card. 300 dpi, exact trim, exact margins — the same file can go to a print house.
- A legible card at 320 characters, always. The fit ladder guarantees the whole cap fits; the old path had no guarantee at all.
The card
| Property | Value |
|---|---|
| Format | 105 × 148 mm portrait (A6), 3 mm bleed → 111 × 154 mm to the edge |
| Render | 1240 × 1748 px at 300 dpi |
| Stock | plain white, flat fill |
| Header ink | #7B1E22 deep maroon |
| Text ink | #1C1A17 near-black |
| Typeface | EB Garamond — Regular for the prayer, SemiBold for the name |
| Name | 120 px, always — maroon, wrapping to at most two lines |
| Prayer | 54 px, always — wrapped to the field |
| Field | 10% margin each side, 24.5% – 84% of card height |
Uniformity, which is the whole point
Shaw, 2026-09-28, after seeing the first pass: "the problem with the cards is that the name at the top is MASSIVE and various different sizes for instance. Won't work, needs to be completely uniform each time. I need it to remain as consistent as possible."
He was right, and the cause was a size ladder: the same card came out at 168 px for Maria and
140 px for a two-part name. There is now one size for the name and one size for the prayer, and
three rules make that hold on every order:
- The name block has a RESERVED height of two lines, whatever the name wrapped to. A one-line name leaves the second line's worth of white space empty, so the prayer starts at the same y on every card. That single choice is most of what makes two cards look like two of the same object.
- The name is never set in any other size. It wraps to two lines first and steps down a rung
only if it cannot — which, measured against the layout's own geometry, no realistic first name
does. (
layout.jsoncarries the measurements.) - The glyph repertoire is one set. The name and the prayer draw from the same characters, so an
unexpected letter can never make the name fall to another tier's size. An unprintable character
is replaced with
?and reported onRenderedCard.nameSubstituted; it does not change the layout.
Asserted by digest in packages/card/test/render.test.ts: the header band and the footer band come
out pixel-identical across cards with different names and different prayers, and the name and
body sizes are equal across a one-line name, a two-line name, a short prayer and the longest the cap
admits.
Layout, top to bottom — these are the invariants, and they are what layout.json holds:
| Element | Position | Size |
|---|---|---|
Lockup (dome-and-arch emblem + two-line wordmark, logo-c-light) |
centred, y = 7.5% of card height |
52% of card width |
| Thin maroon rule with a centred diamond | 3% of card height below the lockup | 46% of card width, 5 px thick |
| Writing field | y = 24.5% to 84.5% |
80% of card width |
| Footer emblem, centred | y = 89.5% |
7.5% of card width |
The footer emblem is the same mark at small size, and it is what tells a viewer the card was issued by us even when the crop cuts the header off. Realism guard: it sits clear of the hand and the fingers, which occupy the lower edge.
Why 105 × 148 mm and not A4
The 2026-09-25 card was A4 — 210 × 297 mm. That is a poster, not a card: a hand holding A4 in a photograph is holding a sheet, and the same sheet in the post is a folded mailer, not the object the buyer was shown. A6 is what a prayer card actually is — it fits a breast pocket, which is where Fr. Marco carries them — and it prints as a flat postcard at a print house with no folding step.
The change also settles the proportions. A6 is exactly half A4 on the short edge and two-thirds on the long, so the card is the same shape it was — portrait, 0.707 — at a size a hand can hold.
How the pipeline uses it
The card is rendered, once, before Pass 2 runs, and attached as the second reference image at every location.
| Location | Card | Why |
|---|---|---|
| Front end (Confessio) | the rendered PNG | — |
| Bump (Vatican Grottoes) | the same rendered PNG | one card, photographed twice; there is no hand-off and nothing to copy |
| Member (monthly) | the rendered PNG, from the subscriptions row |
a member's card carries the member's name and that month's intention |
Pass 2 writes no text: the pack ships no {FIRST_NAME}, no {PRAYER_TEXT} and no
{SPELLED_NAME}, and the prompt says so explicitly. The model's only relationship to the card is to
hold it up without covering a word of it.
The old design, and why both halves of it are gone. The front end used to attach a blank template and be told to write the buyer's words on it in blue ballpoint; the bump used to attach the front end's finished photograph and be told to copy the writing out of it. The second clause only existed because the first one produced something different every render. Printing the card removes the cause and the workaround together.
Files
packages/card/layout.json the geometry — the single source of truth
packages/card/assets/EBGaramond[wght].ttf the variable source font (OFL)
packages/card/assets/fonts/ the two sliced, subsetted statics (built)
packages/card/src/render.ts the layout, the fit ladder, the rasteriser
packages/card/src/png.ts a PNG encoder over the platform's CompressionStream
packages/card/src/generated.ts header, footer and glyph atlases (built)
packages/card/scripts/build-assets.py builds the assets; --check fails a stale build
packages/card/scripts/render-sample.ts renders samples to look at
There is no card MASTER PNG, and brands/<slug>/assets/card/ is gone: the worker renders the card
rather than reading one out of the bucket. push-brand-assets.sh no longer uploads a template.
Rebuilding
pnpm --filter @prayer-engine/card build:assets # after a layout, logo or font change
pnpm --filter @prayer-engine/card render:sample /tmp/cards
pnpm --filter @prayer-engine/card typecheck runs build-assets.py --check first, so a change to
the layout that was never rebuilt fails the build instead of shipping a card that disagrees with its
own description.
The ladder, kept only as a guard
The card's promise is that it is the same card every time, and after 2026-09-28 that includes the type sizes. The ladder below still exists — a card must never be clipped or dropped — but it is sized so that it does not fire, and the tests assert that it does not.
| Sizes | What actually happens | |
|---|---|---|
| Name | 120 → 88 px, at most two lines | 120 px for every realistic first name. A one-line name and a two-line name both occupy two lines of height, so the block below them never moves. The 88 px rung is reached only by a name that cannot be wrapped into two lines — the intake caps names at 24 characters, and 24 of the widest glyph still fits two lines at 88, so it is a backstop. |
| Prayer | 54 → 46 px | 54 px for every order. Measured 2026-09-29: the longest prayer the 320-character cap admits in ordinary English is seven lines at 54 px, and the field holds eight. The lower rung is reachable only by a pathological single-glyph string — 320 characters of the letter W — which intake cannot produce. |
A first name is free text, and no name can break the card. Three rules protect that, in order of how often they apply:
- Intake caps the name at 24 characters, with the live counter and a
maxLengthon the field. - The name wraps to two lines, and a line wider than the field is broken rather than allowed to overflow — so nothing is ever clipped and nothing is ever dropped.
- An unprintable character becomes
?, is reported onRenderedCard.nameSubstituted, and does not change the layout. The card is the same size and the same shape; one character of the name is visibly imperfect and the paid order still renders.
RenderedCard.nameSize, nameLines, nameSubstituted, bodySize and bodyLines are logged on
every order, so a change in any of them is visible in the logs rather than inferred from a
photograph.
What is deliberately NOT on the card
- No date. The delivery window varies (3 days standard, 24 hours rush) and a dated card would be wrong for one of them.
- No place name. The same card goes to two locations — naming one would make the bump's photograph a lie.
- No signature. Fr. Marco signs the letter and the emails; a signature on the card is the one mark a buyer could compare against the persona and find wanting.
- No QR code, no serial number, no website, no price. Nothing that invites the photograph to be read as a document rather than as a card someone's prayer is written on.
What the printed header broke, and how it was found
Found live, 2026-09-25, on the first branded hero run: all seven rows FAILED QA.
H-1 attempt 1: FAIL name word_match=1.00 name="Holy Cards OF ST. PETER'S"
Every photograph was correct. The gate rejected them because the vision read-back transcribed the
printed header as the card's first line, and the name check then compared the brand name to the
buyer's first name. word_match=1.00 on every row is the tell: the text below the header was
perfectly legible and the failure was entirely about which line the model called the name.
Two fixes, both still in place and both now covering all of the card's text:
QA_INSTRUCTIONsayscard_textis the printed text beginning at the printed name line, states that the lockup and wordmark are artwork rather than text, and says they are never the name and never count asnew_text.parseCardTextstrips the header defensively anyway. The instruction reduces the rate; it does not guarantee it, and a model that ignores an instruction must not be able to dead-letter a paid order.
Pinned by three tests in packages/imaging/test/qa.test.ts.
The generalisable finding: a branded card changes the QA contract, not just the picture. Any future printed mark on the card — a date, a place line, a signature — has to be added to that exclusion list in the same commit that adds it to the card, or the gate starts failing good photographs.
Second finding: the assets cannot be generated lazily on a Worker
The renderer needs a font rasteriser, and a Cloudflare Worker has none. The choices were:
- A WASM rasteriser (
@resvg/resvg-wasm,satori+ a WASM yoga): about 1 MB gzipped on the critical path of every paid order, plus a font that has to be carried alongside it. - Rasterise at build time. Every glyph the card can print, at every size on the ladder, once, in
build-assets.py; ship the coverage masks as deflated base64 and let the runtime measure and blit.
The second one won, and it is not close. The runtime does no font work at all — it looks up advance
widths, wraps, and copies 8-bit masks into a buffer. generated.ts is 672 KiB of source, and the
subsetting keeps it there: the source Garamond carries the whole repertoire and the card needs 202
code points.
The trap that cost the first afternoon, recorded because it will bite again: glyph advances are
fractional, and buffer[index] = value at a fractional index on a Uint8Array is silently
discarded — no throw, no warning. The first working build drew the first glyph of every line and
nothing after it, which looks exactly like a broken glyph atlas. blitGlyph rounds the origin once,
and that is load-bearing.
The other trap: a missing glyph must not vanish
The typeface carries Latin text only. A prayer in another script cannot be printed, and the two wrong answers are worse than failing:
- Drop the characters and the card silently carries a different prayer from the one the buyer wrote — and the QA word-match, which compares the read-back to the source, would then fail a correct card.
- Substitute the nearest Latin letter and the card carries a misspelling.
So the renderer folds to NFC (the typeface has é, not e + a combining acute, and a decomposed
source would match nothing in the QA gate), replaces an unprintable character with ? so its word
does not disappear, and throws UnsupportedCardCharacterError. The survey and the copy are
English-only and the gate is a backstop, but a backstop that fails loudly is the only kind worth
having.
Verified 2026-09-28
Rig: apps/worker/scripts/render-cards.ts (a JSON job list on stdin, for the Python rigs) and
packages/card/scripts/render-sample.ts.
- Render (2026-09-28, before the 320-character cap moved the body to 54 px on 2026-09-29): 1240 × 1748 at 66 px body and 120 px name for every sample — a 77-character prayer, a 199-character one, a two-part name, an accented name and one with a digit; byte-identical output for identical input.
- Uniformity: the header band and the footer band are pixel-identical across six cards with
different names and different prayers, verified by digest outside the test suite. Asserted in
packages/card/test/render.test.ts, which also pins the name and body sizes and the reserved name block. - Delivered file, through the live Worker on real workerd: 864×1152 at 1.82 MB in, 720×960 JPEG at 88 KB out, footer emblem present, printed name crisp at 1:1.
- Gates:
pnpm typecheck0, 702 unit tests, Playwright 28/28.
Ancestry
- 2026-09-21 — the card was a plain white unlined index card with nothing on it. The bump was given the front end's photograph to copy from, because two independent renders never matched.
- 2026-09-25 — the card became a branded A4 template attached as a blank reference (
no decoration eversuperseded). The header stopped being invented; the writing was still generated. - 2026-09-28 — the card became a print. The writing stopped being generated at all.
- 2026-09-28, later — the print became uniform. Shaw: "the name at the top is MASSIVE and various different sizes… needs to be completely uniform each time." One name size, one prayer size, a reserved two-line name block so the prayer starts at the same y on every card, and one glyph repertoire so an odd character cannot change the size. The footer emblem was doubled in size and named in the prompt clause, because the model was dropping it. And the delivered file was shrunk to 720×960 JPEG — "so people can't open it up in a new tab and review/scrutinize them."