BUILD DOCS

CARD-SPEC — the printed prayer card

prayer/CARD-SPEC.md

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:

  1. 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.
  2. 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.json carries the measurements.)
  3. 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 on RenderedCard.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:

  1. Intake caps the name at 24 characters, with the live counter and a maxLength on the field.
  2. 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.
  3. An unprintable character becomes ?, is reported on RenderedCard.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:

  1. QA_INSTRUCTION says card_text is 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 as new_text.
  2. parseCardText strips 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 typecheck 0, 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 ever superseded). 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."