Typst Client Render — render economics, font system, design canon
Document compilation happens in the browser, never on the server. The Typst WASM compiler + renderer (~25 MB) is lazy-loaded from a CDN on first use; fonts are loaded into the WASM virtual filesystem; Typst source is generated on demand by walking the canonical Node tree. The CV/CL editor's preview loop and the export-PDF action both go through the same pipeline.
Data flow
Node tree → nodeToTypst(tree, format) → Typst source → $typst.svg() / $typst.pdf()
No template files. The serializer (lib/domain/src/node-to-typst.ts) is
the only thing that converts the document model to Typst syntax. Worker
entrypoints (renderTreeResolvedPreviewSegments, exportTreeResolved,
countTreeResolvedPages) live in lib/domain/src/typst-worker.ts. The
worker bridge sits at lib/domain/src/typst-client.ts. Call sites are in
ui/src/lib/components/cvl/CvGenerator.svelte.
Why client-side
- Cost discipline. Server-side PDF rendering on Cloudflare Workers
burns CPU budget that scales with active users. Browser CPU is free.
Per
PRINCIPLES.md#1 (compute economics) and #8 (client compute default), server rendering is rejected by design. - Iteration speed. Edits show in the preview in milliseconds; there is no round trip to a render service.
- Privacy. The user's CV content does not leave their browser to be rendered.
Font system
Multiple font families are selectable per workspace via format.font:
| Font | Type | Variants | Files |
|---|---|---|---|
| Archivo | Sans-serif (default) | Regular, Medium, Bold, Italic | .ttf |
| IBM Plex Serif | Serif | Regular, Medium, Bold, Italic | .woff2 |
| Source Serif 4 | Serif | Regular, Medium, Bold, Italic | .woff2 |
| EB Garamond | Serif | Regular, Medium, Bold, Italic | .woff2 |
Font files in ui/static/fonts/ are loaded into the Typst WASM VFS at
init via loadFonts() in lib/domain/src/typst-client.ts. The pipeline
is format.font → formatToInputs() → sys.inputs.font → #set text(font: cv-font).
The font family name in format.font must match the name embedded in the
font file exactly — a mismatch yields a fallback render that the user
will notice.
Adding a new font: drop the file in ui/static/fonts/, add its URL to
FONT_URLS, and add the <option> to FormatEditor.svelte.
CV header layout
The header line below the name is per-language configurable.
format.headerLayout?: Record<string, string[]> keys are language codes,
values are ordered arrays of personal-info field IDs. EN is the base
layout; other languages fall back to EN if not customised.
Overflow detection uses the Typst worker's measure pipeline
(measureAll / measureOverflow) to render the header at full page
width and check whether it overflows. The editor surfaces an ! (red)
or ✓ (green) indicator on the header chip.
Link styling
isLinkCapable(id) enumerates the items that can be link-styled (email,
phone, social.*). format.linkItems?: string[] selects which of those
items render as links; undefined means "all link-capable items styled,"
and an empty array means "none." CV and CL each have their own colour and
underline toggles (linkColor, linkUnderline). The serializer reads the
final FormatSettings and emits a #show link rule in the preamble.
Design guide — six semantic colours
The CV/CL output and the editor UI both follow the DaisyUI semantic colour system. There are six tokens plus neutral; no raw Tailwind palette colours appear in components.
| Token | Signal | Used for |
|---|---|---|
primary |
"Act on this" | Buttons, links, focus rings, active states. |
secondary |
"AI / system" | AI-generated badges, provider features. |
success |
"Good news" | Resolved, valid, connected, positive outcomes. |
warning |
"Needs attention" | In progress, pending, caution, star ratings. |
error |
"Urgent / danger" | Overdue, failures, delete, knockouts. |
info |
"FYI" | Informational, passive, third-party sources. |
| neutral | "Background" | Structural UI, borders, disabled text, containers. |
Opacity uses the /N suffix (bg-error/10, border-primary/30) in 10%
increments. Dynamic tone selection uses the TONE constant from
@cv/domain/theme; inline styles use themeVar() and themeBg().
Six of the seven tokens are user-configurable per workspace in the Visuals
modal. Neutral is intentionally not configurable because the DaisyUI
neutral default is near-black and is wrong for "passive / inactive"
semantics; the hardcoded slate gray is stable across themes.
What this rules out
- Server-side PDF rendering, even as a fallback. The cost story breaks.
- Raw Tailwind palette colours (
red-500,violet-600) in components. Use the semantic tokens. - Dynamic class construction like
`text-${tone}`— Tailwind JIT will not compile it. UseTONE[tone].text. - Adding new fonts without registering them in the VFS. The Typst compiler has no awareness of system fonts.
The client-side render contract is the reason a workspace can compile dozens of CV variants per day without showing up on the CF Worker bill.