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.

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. Use TONE[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.

Source: wiki/content/canon/typst-client-render.md