TAGS: designsystems, webdev, css, architecture
draw-your-font did the rounds this week — photograph your handwriting, get a real TTF/WOFF back. Nice hack, and a good reminder that generating a brand asset is the easy half. Keeping that one asset identical across a React app, a marketing site, a generated PDF, and a 1200×630 share image is the half that quietly rots.
That rot has a specific cause: those four surfaces usually get built by different people at different times. The app gets built first, the landing page gets bolted on before launch, the PDF export is a ticket nobody wanted, and the OG card is made in Figma the morning of. Each one re-declares the brand from memory. Six weeks later the accent color exists in four slightly different hexes.
Here's the pipeline we use to stop that, and the three places it broke.
Rule 1: tokens are primitives, never references
The temptation is to write tokens as CSS:
:root { --accent: #1f5f4b; --accent-weak: color-mix(in srgb, var(--accent) 12%, white); }
This works right up until a consumer that isn't a browser needs the value. Chrome's Page.printToPDF header template can't resolve your var(). Neither can a canvas-based OG generator, nor an email, nor a <meta name="theme-color">.
So the source of truth is a flat JSON of resolved primitives, and everything else is generated:
// tokens.json → four artifacts
const t = JSON.parse(await readFile("tokens.json"));
await write("app/tokens.css", `:root{${vars(t)}}`); // web app
await write("site/tokens.css", `:root{${vars(t)}}`); // landing page
await write("pdf/theme.js", `export default ${json(t)}`); // print + header/footer
await write("og/theme.js", `export default ${json(t)}`); // share card renderer
const vars = t => Object.entries(flatten(t))
.map(([k, v]) => `--${k}:${v}`).join(";");
Any derived value — a 12% tint, a hover state — gets computed in the build step and written out as a literal. If a consumer has to do color math at runtime, it will do it differently than the others eventually.
Where it actually broke
Chrome's PDF header/footer is a separate document. headerTemplate renders in its own tiny page with no access to your stylesheet, no external fonts, and it silently ignores most of what you send it. Everything has to be inlined, sized in pt, and the font has to be a data URI of the same file the site loads:
headerTemplate: `<style>@font-face{font-family:B;src:url(data:font/woff2;base64,${b64})}
div{font:8pt B;color:${theme.ink.muted};padding:0 14pt}</style>
<div><span class="title"></span></div>`
Backgrounds vanish in print. Any token used as a fill needs print-color-adjust: exact on the element, not just the page. We set it once on a .binder * selector after losing an afternoon to a header block that rendered correctly in the browser preview and white in the output.
Page breaks are a token too. Section spacing that looks right on a scrolling page produces orphaned headings on paper. We added break-inside: avoid and a --section-gap-print value to the same file, so the print rhythm is versioned alongside the screen rhythm instead of living in one person's head.
Versioning across repos
The app and the landing page deploy separately, so tokens.json is published as a tiny internal package and pinned by version in both. Not because the semver matters, but because it makes drift a diff instead of a discovery. When the accent shifts, you get one PR touching one file, and the next deploy of each surface picks it up.
The payoff is that launch assets stop being a separate project. The OG card is a headless render of a component that imports the same theme; the ad thumbnails are crops of it. Change the accent, run the build, and the share preview, the report cover, and the landing hero all move together.
That's the whole argument for shipping the product and its surrounding surfaces as one job rather than two: not that it's faster, but that consistency is a build artifact when the same team owns the pipeline, and a manual chore when it isn't.
We built this for CivicBinder — a service that runs web-accessibility audits for public agencies and generates the evidence binder as a PDF. Same pipeline: one tokens file, four renderers.
Top comments (0)