Document 03 of 14 · ARCHITECTURE.md
How a template becomes a customized PDF that someone paid for. Written for the owner first (who reads HTML and CSS and follows the rest), then for the Claude Code session that builds it. Facts about vendor limits were checked on 2026-09-16 and carry their source; re-check before relying on a number a year from now.
A template is a folder of plain HTML and CSS plus two small JSON files. The customizer page loads that HTML into an iframe and, as the visitor types, a shared script (studio.js) writes their words into the marked places and swaps the colour theme. When they pay, a small function on Vercel saves what they typed, sends them to Stripe, and when Stripe says "paid" it sends the same HTML plus the same values to a rendering service that returns a PDF. The PDF goes into a private bucket on Supabase, the visitor gets a link on the success page and by email, and the link keeps working for a year. The free version of every template is the same HTML rendered once, by hand, with the default values, and committed as a static file, so it costs nothing to serve.
studio/<slug>/studio/invoice/
template.html the sheet(s)
template.css print-first styles
fields.json what the customizer exposes
meta.json catalogue data
shots/ generated: preview.png, og.png, pin.png
template.htmlA complete HTML document (so it can be opened on its own in a browser and printed), with hooks the runtime understands:
<div class="sheet">
<header>
<img data-bind-src="logo" data-show="logo" alt="">
<h1 data-bind="business_name" data-max>Studio & Co.</h1>
<p data-bind="address" data-max>12 Bay Street, Portland</p>
</header>
<table>
<tbody data-list="items">
<template>
<tr>
<td data-bind="description" data-max>Design retainer</td>
<td data-bind="qty">1</td>
<td data-bind="rate" data-format="money">1,200.00</td>
<td data-bind="line_total" data-format="money">1,200.00</td>
</tr>
</template>
</tbody>
</table>
<section data-show="show_notes">
<p data-bind="notes" data-max>Thank you for your business.</p>
</section>
<p data-bind="total" data-format="money" data-computed>1,200.00</p>
</div>
data-bind="key" gets the value of key as text (never HTML).data-bind-src sets an image src from a data URL the visitor uploaded.data-show="key" hides the element when the value is false or empty (hidden attribute, with a [hidden]{display:none} rule restated in the template because display:grid beats the UA rule).data-list="key" repeats the inner <template> once per row of an array value.data-max marks a box whose contents must fit; the runtime measures it.data-format applies money, date or number formatting from meta.json's locale.data-computed marks a value the compute hook produces (totals), never typed.template.css@page { size: Letter; margin: 0; } /* the default only; see below */
.sheet { width: 8.5in; height: 11in; padding: .5in; box-sizing: border-box; background: var(--t-paper); color: var(--t-ink); }
html.a4 .sheet { width: 210mm; height: 297mm; }
:root { --t-paper:#fff; --t-ink:#1a1a1a; --t-accent:#b3261e; --t-muted:#6b6f76; }
An @page rule cannot be selected by a class, so the paper size is switched by the runtime, which writes a <style id="page-size">@page { size: A4; margin: 0 }</style> (or Letter, or Letter landscape when meta.orientation says so) into the head and toggles the a4 class for the sheet's own dimensions. Proven in spikes/render/: the same document printed at 612 by 792 and 595 by 842 points.
Fonts are @font-face rules pointing at /assets/fonts/<face>-<weight>.woff2, static instances, one file per weight and style, never the variable font. The render proof found that Chromium writes a variable font into the PDF as Type 3 outlines with no font program and four times the file size, and writes a static instance as a proper embedded TrueType. The site shell may use the variable face on screen; a template may not. The renderer sees the files through a <base href> (section 4), the preview sees them relative to the site. Never a Google Fonts link.
fields.json{
"groups": [
{ "title": "Your business", "fields": [
{ "key": "business_name", "label": "Business name", "type": "text", "max": 40, "default": "Studio & Co." },
{ "key": "logo", "label": "Logo", "type": "image", "hint": "PNG or JPEG, square works best" },
{ "key": "notes", "label": "Notes", "type": "long", "max": 220, "assist": true,
"assistBrief": "A short, warm payment note at the foot of an invoice." }
]},
{ "title": "Line items", "fields": [
{ "key": "items", "type": "list", "max": 12, "row": [
{ "key": "description", "type": "text", "max": 60 },
{ "key": "qty", "type": "number", "min": 0, "max": 9999 },
{ "key": "rate", "type": "number", "min": 0, "max": 999999 }
]}
]},
{ "title": "Options", "fields": [
{ "key": "currency", "type": "select", "options": ["USD","EUR","GBP","CAD","AUD"], "default": "USD" },
{ "key": "show_notes", "type": "toggle", "default": true },
{ "key": "theme", "type": "theme" },
{ "key": "size", "type": "size", "default": "letter" }
]}
],
"compute": "items.forEach(i => i.line_total = i.qty * i.rate); v.total = items.reduce((s,i) => s + i.line_total, 0)"
}
The compute string is the only arithmetic a template may carry, and it is evaluated in a sandboxed function with v (the values) as its sole argument. The same string runs in the preview and in the render, so the two cannot disagree. Every max, min and option list here is enforced twice: in the customizer as a courtesy and in api/checkout.js as the rule.
meta.json{
"slug": "invoice", "title": "Invoice Template", "category": "business",
"tier": 12, "pages": 1, "sizes": ["letter","a4"],
"themes": {
"ledger": { "--t-accent": "#1f4e79", "--t-muted": "#5b6b7a" },
"light-ink": { "--t-accent": "#333", "--t-muted": "#777", "printerFriendly": true }
},
"fonts": ["newsreader", "inter-tight"],
"phrase": "invoice template pdf",
"family": "business-set",
"free": { "size": "letter", "theme": "ledger", "credit": true },
"version": 3
}
version bumps whenever template.html or template.css changes in a way that alters output; orders record the version they were rendered with.
assets/js/studio.jsOne file, used in four places: the customizer preview, the template page's live sheet, the homepage hero and the render service. It exposes:
apply(values, meta): writes text with textContent, sets hidden, clones list rows, sets theme variables on .sheet, toggles the a4 class, runs compute, formats numbers. Idempotent: calling it twice with the same values changes nothing.overflow(): for every [data-max] box, compares scrollWidth/scrollHeight against the client box and returns the keys that overflow. The customizer calls it after every apply and disables the buy button while the list is non-empty. This is the behaviour that lets the site promise "we will not sell you a broken page".document.fonts.ready, applies window.__values if present, then sets data-ready="1" on <html>. The renderer waits for that attribute, so a PDF is never captured with fallback fonts.The customizer talks to the iframe with postMessage; the render service never needs to, because the values arrive inline (section 4).
api/render.js assembles one HTML string and makes one request:
studio/<slug>/template.html from the deployment (the folder is shipped with the function via includeFiles in vercel.json).<head>: <base href="https://www.artisanpdfstudio.com/"> so every relative font and image URL resolves to the live site; <script>window.__values = {…}</script> with the order's values JSON (serialised with < escaped as <); and <script src="/assets/js/studio.js">.POST https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/browser-rendering/pdf with the bearer token, a JSON body of { html, waitForSelector: { selector: "html[data-ready]" }, pdfOptions: { format: "Letter", printBackground: true, preferCSSPageSize: true } }. The response body is the PDF.A4 (and the a4 class in the values), so every paid order gets both sizes.orders bucket at orders/<order_id>/<slug>-letter.pdf and …-a4.pdf, set the order to ready.The endpoint accepts html, addStyleTag, addScriptTag, setJavaScriptEnabled, gotoOptions, viewport, pdfOptions and waitForSelector, returns PDF bytes, and is documented at https://developers.cloudflare.com/browser-run/quick-actions/pdf-endpoint/ (the product was renamed from Browser Rendering on 2026-04-15). Limits that matter: Workers Free allows ten browser-minutes a day and one quick-action request every ten seconds; Workers Paid ($5 a month) includes ten browser-hours, then $0.09 an hour, billed on duration only. A render takes a few seconds, so ten hours is on the order of ten thousand PDFs. Sources: https://developers.cloudflare.com/browser-run/pricing/ and https://developers.cloudflare.com/browser-run/limits/
Retry rule: a 429 or 5xx from the endpoint leaves the order at paid with render_attempts incremented and the function returns; the webhook's own retry (section 6) and the success page's poll both call render again with backoff. After five attempts the order goes to render_failed and the owner gets an email.
If Browser Run is ever unavailable or too slow, the same HTML string can be rendered by @sparticuz/chromium with puppeteer-core in the function itself. Checked 2026-09-16: @sparticuz/chromium v153 pairs with the current puppeteer-core, the package is ESM-only (the function must be .mjs), it unpacks about 130 MB into /tmp, and it needs fonts supplied through its fonts directory because only Open Sans ships with it. Vercel functions on Fluid compute get 2 GB memory and a 300-second ceiling on every plan, and the 250 MB bundle limit still applies, all of which this fits. Sources: https://github.com/Sparticuz/chromium/releases/tag/v153.0.0, https://vercel.com/docs/functions/limitations, https://vercel.com/docs/functions/configuring-functions/memory
Not built in Phase 1. Documented so that switching is a known job and not a research project.
spikes/render/ prints a stand-in invoice through the same DevTools call Browser Run makes and checks the files. Results: Letter and A4 from one document by switching a class; data-ready gating on document.fonts.ready works; the overflow guard measures real boxes (and caught a box in the proof itself that was one line too short); fonts embed only when they are static instances. The pdfcheck in docs/QA.md asserts the last point on every file: FontFile2 present, Type3 absent.
These never touch the cloud renderer. studio-src/free.mjs and shots.mjs drive a local Chrome over the DevTools protocol, exactly the way measureandbuy's og/generate.mjs does, and commit the output: templates/<cat>/<slug>/free/<slug>-letter.pdf, shots/preview.png (page one at 1200 px wide), og/<slug>.png (1200 by 630, the sheet at an angle on the desk with the title), pins/<slug>.png (1000 by 1500, the sheet upright with the title and "free version" flag). Hand-run, no build step, same reasoning as the generators decision on measureandbuy.
create table orders (
id uuid primary key default gen_random_uuid(),
token text not null, -- 32 random bytes, base64url; the capability in the link
status text not null default 'pending'
check (status in ('pending','paid','ready','render_failed','refunded')),
slug text not null,
template_version int not null,
values jsonb not null, -- what they typed, validated
size text not null default 'letter',
email text, -- from Stripe after payment
amount_cents int not null,
currency text not null default 'usd',
stripe_session_id text unique,
stripe_payment_intent text,
pdf_letter text, -- storage path
pdf_a4 text,
render_attempts int not null default 0,
downloads int not null default 0,
created_at timestamptz not null default now(),
paid_at timestamptz,
ready_at timestamptz,
expires_at timestamptz -- paid_at + 1 year; links refuse after
);
create table order_events ( -- the audit trail: every webhook, render, email, download
id bigserial primary key,
order_id uuid references orders(id),
kind text not null, -- 'stripe.checkout.session.completed', 'render.ok', 'email.sent', 'download', ...
stripe_event_id text unique, -- makes webhook handling idempotent
detail jsonb,
created_at timestamptz not null default now()
);
create table assist_uses ( -- rate limit for "Write it for me"
ip_hash text not null,
day date not null,
count int not null default 0,
primary key (ip_hash, day)
);
alter table orders enable row level security;
alter table order_events enable row level security;
alter table assist_uses enable row level security;
-- No policies on purpose. Only the service role, from Vercel functions, touches these tables.
Storage: one private bucket, orders. Files are reached only through signed URLs minted by api/order.js (24 hours each, re-minted on every visit until expires_at). Supabase's createSignedUrl(path, seconds) has no upper bound on the expiry, but short links re-minted on demand are the safer shape because a leaked link dies in a day. Free plan: 1 GB storage, 5 GB egress a month, 50 MB per file; a two-size order is about 300 KB, so a thousand orders is a third of a gigabyte. Sources: https://supabase.com/docs/reference/javascript/storage-from-createsignedurl, https://supabase.com/docs/guides/platform/manage-your-usage/storage-size
A free Supabase project pauses after seven days without activity. During the build, and in any quiet week before launch, that would turn every purchase into an error. Either the weekly GitHub Action (section 9) runs a one-row query to keep it warm, or the project goes to Pro ($25 a month) the day real keys go in. Source: https://supabase.com/docs/guides/deployment/going-into-prod
Which project. The existing "PDF Farm" project, ahjsvadwxacschqgdahj, in the Synergy org, us-east-1, already paid for on the Pro plan. Owner's call, 2026-09-16: it is the consolidation point for the earlier attempts. It is reset in place rather than extended, because nothing in it is wanted:
supabase/migrations/20260916000001_reset_pdf_farm.sql drops the old trigger on auth.users, the eight tables, the seven helper functions, the app_role enum, and the two public buckets with their one object. Every statement is if exists, so it can be re-run.supabase/migrations/20260916000002_orders.sql creates the schema above and the private orders bucket.SETUP.md lists them), rename the project "Artisan PDF Studio", create a new secret API key for the Vercel functions and disable the legacy anon and service_role keys once nothing else uses them, because earlier attempts' deployments may still hold copies. The one auth user can be deleted or left; the new site has no accounts until Phase 3.The Lovable-managed project the old app uses (wonwshiaormnewhwtwwn) is not visible from the Synergy org and goes away when the owner deletes the Lovable project.
Phase 3 adds customers (magic-link accounts) and a passes table for the all-access subscription; nothing in v1 is shaped in a way that blocks that.
api/, Node, no framework)Vercel serves static files as-is and turns each file under api/ into a function with no configuration. Node 24 is the default runtime for new projects. Source: https://vercel.com/docs/functions/quickstart
| Route | Method | Does | Guards |
|---|---|---|---|
/api/checkout |
POST | Validates {slug, values, size} against fields.json (types, enums, lengths, list caps, image data URL type and size). Reads the price from meta.json, never from the request. Inserts the order (pending) with a fresh token. Creates a Stripe Checkout Session with inline price_data, metadata.order_id, success_url=/order/?id=…&t=…, automatic_tax if enabled. Returns the Stripe URL. |
20 per hour per IP |
/api/stripe-webhook |
POST | Verifies the signature with the raw body. Handles both checkout.session.completed and checkout.session.async_payment_succeeded (delayed payment methods complete unpaid first). Inserts an order_events row keyed by stripe_event_id; a duplicate insert means "already handled", return 200. Marks the order paid, records email and payment intent, calls render, sends the email. Also handles charge.refunded → refunded. |
Stripe signature; idempotent by event id and by session id |
/api/render |
POST | Section 4. Internal. | INTERNAL_KEY header |
/api/order |
GET | {id, t} → status, and when ready, two fresh signed URLs. Increments downloads on a ?dl= hit. If the order is still pending a minute after creation, asks Stripe for the session directly and completes it (belt and braces for a delayed webhook). Refuses after expires_at or when refunded. |
token match |
/api/draft |
POST | Section 7. | Turnstile token, 10 per day per IP |
/api/subscribe |
POST | Adds the address to the Resend contact list with the "paper set" tag, triggers the welcome email with the bundle link. | Turnstile token, 5 per day per IP |
Stripe facts that shaped this, checked 2026-09-16: metadata allows 50 keys with values up to 500 characters, which is why the values live in Postgres and only order_id rides on the session; webhook delivery is at-least-once with retries for up to three days, which is why fulfilment is keyed on the session id; inline price_data works with automatic_tax. Sources: https://docs.stripe.com/metadata, https://docs.stripe.com/checkout/fulfillment, https://docs.stripe.com/api/checkout/sessions/create
Stripe Tax, if turned on, is 0.5% of volume on Checkout with no code beyond automatic_tax: {enabled: true} and a tax code on the line item; Stripe's list names txcd_10000000 ("General, electronically supplied services") for digital downloads sold outside the US and has a separate digital-goods code for US sales; pick both from the list in the dashboard rather than from memory. Source: https://docs.stripe.com/tax/digital-products
api/draft.js)For fields marked assist: true. The request carries {slug, key, brief, current}; the function loads the field's label, limit and assistBrief from fields.json (the client's own copy is not trusted), verifies the Turnstile token, checks assist_uses, then calls the Anthropic API with the official Node SDK (@anthropic-ai/sdk), model claude-opus-5, a short system prompt ("You write the wording that goes on a printed {template}. Plain, warm, no cliché. At most {max} characters. Return only the wording."), the brief as the user turn, and a structured output schema { text: string } so nothing but the wording comes back. It trims to max and returns it. The field shows the draft with an Undo. Enable the server-side refusal fallback the SDK offers so a rare declined request still returns a draft rather than an error.
Cost, at Opus 5's $5 per million input tokens and $25 per million output: a draft is about 400 tokens in and 80 out, so roughly $0.004 each; ten thousand drafts is about $40. If that ever matters, claude-haiku-4-5 does this job at a fifth of the price, and that is the owner's call, not a default. Model IDs and prices from the claude-api skill's table, cached 2026-06-24.
The function has a hard monthly ceiling (an env var, default 5,000 drafts) and simply returns "Not available right now" past it. Nothing about the page depends on it working.
studio-src/copy.mjs, hand-run)Given a slug, it reads meta.json and the template's default text and asks Claude for a first draft of the description, five FAQ pairs, the pin title and description and the OG line, writing them to studio/<slug>/copy.draft.md. A person rewrites that into meta.json's copy block; the draft is never committed as-is and the human-voice lint runs on the result. This is a design-tool script, not a build step, and it does not run on Vercel.
Nowhere on the site does the word "AI" appear. The button says "Write it for me". House rule.
meta.json on the server. The client sends a slug, not a price.fields.json on the server: type, enum membership, length, list length, number range. Unknown keys are dropped. Images must be data:image/png or data:image/jpeg, under 400 KB, and the client downsizes to 1200 px before upload (so most logos are under 100 KB). SVG is refused because it can carry script.textContent. There is no path from a value to markup, in the preview or in the render.<, > and & escaped as \uXXXX, so a value cannot close the tag.INTERNAL_KEY. The order route needs the per-order token, which is 256 bits of randomness and appears only in the success URL and the email.expires_at (one year) and on refund..env.example with names and no values.vercel dev runs the functions beside the static files; stripe listen --forward-to localhost:3000/api/stripe-webhook replays events; python3 studio-src/serve.py is enough for pure front-end work. Node 22 or later, pdffonts and pdftotext from poppler, Chrome for the shot scripts.expires_at.vercel.json carries permanent redirects for the routes the Lovable app exposed, so a stray indexed URL lands somewhere useful: /auth, /dashboard, /admin, /admin/*, /payment-success to /; /buy/* and /customize/* to /templates/.main, Vercel builds nothing and serves. Verify live: a test purchase in Stripe test mode is only possible with test keys, so the live check after launch is the free download, the customizer on a phone, and the order-status endpoint for a known order.| What breaks | What happens | What the visitor sees |
|---|---|---|
| Renderer returns 429/5xx | Order stays paid, retry with backoff from the webhook retry and the success-page poll; after 5 attempts render_failed, owner emailed |
"Your PDF is taking longer than usual. Your order number is … and a link will be emailed within the hour." Never a spinner forever. |
| Stripe webhook delayed | Success page polls /api/order; after 60 s the order route asks Stripe for the session itself |
A slightly longer wait, then the buttons. |
| Supabase paused (free plan) | Checkout cannot insert the order | "We can't take orders right now." The weekly keep-warm exists to make this not happen; Pro removes it. |
| Cloudflare Browser Run withdrawn or changed | Switch render.js to section 4.2 |
Nothing, if done before it bites. |
| Draft assist over budget or down | Button returns "Not available right now" | They type it themselves. |
| A template's fonts fail to load in the render | data-ready never sets; waitForSelector times out; order retries |
Same as a renderer failure; the smoke test catches this before a visitor does. |
| Service | At launch | When it grows |
|---|---|---|
| Vercel | Hobby, $0 (personal projects only; commercial use needs Pro, $20) | Pro, $20 a month, which this site needs on Vercel's terms as soon as it sells |
| Supabase | PDF Farm, already paid for ($10 a month on the Pro org); nothing new | same, until traffic warrants more compute |
| Cloudflare Workers Paid (Browser Run) | $5 a month | $5 plus $0.09 per browser-hour past ten |
| Stripe | 2.9% + $0.30 per sale; Stripe Tax 0.5% if on | same |
| Resend | Free: 3,000 emails a month, 100 a day, 1,000 contacts | $20 a month for sending, marketing tiers from $40 at 5,000 contacts |
| Anthropic | pennies | tens of dollars at ten thousand drafts a month |
| Domain | already owned |
Note on Vercel's Hobby plan: its terms restrict it to non-commercial use. A site that takes payments should be on Pro from the day live keys go in. That is the one line item above that is not optional.