How to generate system font stacks programmatically
- Step 1Fetch the option schema — Call `GET /api/v1/tools/system-font-stack-generator` to confirm the two option fields and their allowed values: `sansOrSerif` (`sans-serif` | `serif` | `monospace`) and `targetOS` (`all` | `mac` | `windows` | `linux` | `ios` | `android`).
- Step 2Pair the JAD runner once — Install and pair the @jadapps/runner so the local HTTP API is available at `http://127.0.0.1:9789`. Pairing is a one-time step; after that the runner answers tool-run requests locally.
- Step 3POST the options as JSON — Because this tool takes no file, send a JSON body — `{ "sansOrSerif": "sans-serif", "targetOS": "all" }` — to `http://127.0.0.1:9789/v1/tools/system-font-stack-generator/run` with `Content-Type: application/json`. The response contains the same CSS the browser tool emits.
- Step 4Capture the CSS output — Write the returned CSS to your tokens file (e.g. `tokens/fonts.css`). It already includes the `:root { --font-system-… }` variable and a body rule — strip the body rule if you only want the variable.
- Step 5Or skip the runner: keep the stacks as static data — Since the lists never change, you can also commit the 18 stacks as a small data file in your repo and write a transform that emits CSS, Swift, and Kotlin from it. No runner needed for this path — it's pure code generation from data you control.
- Step 6Gate the output in CI — Whichever path, generate into a tracked file and fail the build if it drifts (`git diff --exit-code tokens/fonts.css`). Because generation is deterministic, a non-empty diff means someone hand-edited the generated file — exactly what you want to catch.
Runner API contract
This tool is file-less (needsFile: false), so the runner accepts a JSON options body — not a multipart upload. Response is text/css.
| Aspect | Value |
|---|---|
| Schema discovery | GET /api/v1/tools/system-font-stack-generator |
| Run endpoint (local runner) | POST http://127.0.0.1:9789/v1/tools/system-font-stack-generator/run |
| Request body | JSON: { "sansOrSerif": "…", "targetOS": "…" } (no file) |
| Content-Type | application/json |
sansOrSerif | sans-serif (default) · serif · monospace |
targetOS | all (default) · mac · windows · linux · ios · android |
| Output | text/css — :root variable + body rule, filename like system-sans-serif-all.css |
The 18 stacks as a static-data matrix
Style × OS scope. If you keep the stacks as repo data, this is the shape — three styles, six OS scopes. Counts are the comma-separated entry totals.
| Style \ OS | all | mac | windows | linux | ios | android |
|---|---|---|---|---|---|---|
| sans-serif | 10 | 5 | 5 | 7 | 5 | 4 |
| serif | 9 | 6 | 6 | 6 | 5 | 5 |
| monospace | 9 | 6 | 5 | 5 | 5 | 5 |
Path A (runner) vs Path B (static data)
Both are deterministic and CI-friendly. Pick based on whether you want zero dependencies or zero hand-maintenance.
| Dimension | Path A — runner POST | Path B — static data in repo |
|---|---|---|
| Dependency | @jadapps/runner paired locally | None — pure code generation |
| Output formats | CSS (as the tool emits it) | Anything you write a transform for (CSS/Swift/Kotlin) |
| Stays in sync with JAD's curated stacks | Yes — runner uses the same data | Only if you re-copy when JAD updates them |
| Network / upload | Local only (127.0.0.1), no upload | None |
| Best for | Web CSS, no maintenance | Multi-platform tokens, full control |
Cookbook
Copy-paste automation recipes. Path A uses the local runner; Path B treats the stacks as data you own and fans them across platforms.
Path A — generate the body CSS via the runner
ExampleFile-less tool, so the body is JSON. The runner returns the same :root variable + body rule the browser tool produces, written straight to your tokens file.
# Discover options first
curl -s http://127.0.0.1:9789/v1/tools/system-font-stack-generator/schema
# Generate (no file — options only)
curl -sS -X POST \
http://127.0.0.1:9789/v1/tools/system-font-stack-generator/run \
-H 'Content-Type: application/json' \
-d '{"sansOrSerif":"sans-serif","targetOS":"all"}' \
-o tokens/fonts-sans.cssPath A — all three styles in one prebuild script
ExampleLoop the three styles through the runner to emit sans, serif, and mono token files. Drop into npm run prebuild so the tokens regenerate on every build.
// scripts/gen-system-fonts.mjs
const styles = ['sans-serif', 'serif', 'monospace'];
for (const s of styles) {
const res = await fetch('http://127.0.0.1:9789/v1/tools/system-font-stack-generator/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sansOrSerif: s, targetOS: 'all' }),
});
const css = await res.text();
await fs.writeFile(`tokens/fonts-${s}.css`, css);
}Path B — stacks as repo data, fan out to CSS + Swift + Kotlin
ExampleThe lists never change, so commit them once and transform. One source, three platform outputs, guaranteed consistent — the real win of doing it programmatically.
// fonts.data.js (committed once)
export const SANS_ALL = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif';
// build.js
writeFile('web/fonts.css', `:root { --font-sans: ${SANS_ALL}; }`);
writeFile('ios/Fonts.swift', `let systemSans = "${SANS_ALL}".components(separatedBy: ", ")`);
writeFile('android/Fonts.kt', `val systemSans = listOf(${SANS_ALL.split(', ').map(f=>`"${f}"`).join(', ')})`);CI drift guard
ExampleGenerate into a tracked file and fail the build if the working tree changes. Because generation is deterministic, any diff means a hand-edit slipped in.
# .github step - run: node scripts/gen-system-fonts.mjs - run: git diff --exit-code tokens/ # exits non-zero if the generated CSS no longer matches # what the script produces → build fails, drift caught
Strip the body rule, keep only the variable
ExampleThe tool emits both a :root variable and a body rule. In a token pipeline you usually want just the variable. Slice it out after generation.
// after fetching css from the runner:
const varOnly = css.match(/--font-system-[\w-]+:[^;]+;/)?.[0] ?? '';
// → --font-system-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", ...;
await fs.writeFile('tokens/_font-var.css', `:root { ${varOnly} }\n`);Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Sending a multipart file upload to the runner
400 invalidThis tool is file-less. POSTing multipart/form-data with a file=@… field is wrong — send a JSON options body with Content-Type: application/json instead. The two fields are sansOrSerif and targetOS; no font, no upload.
Using the wrong style value
invalid optionThe style field is sansOrSerif and its values are sans-serif, serif, monospace — note it's sans-serif, not sans, and monospace, not mono. Passing sans or mono is not a valid option. Confirm against GET /api/v1/tools/system-font-stack-generator before scripting.
Runner not paired / port 9789 closed
connection failedIf the @jadapps/runner isn't running or paired, the POST to 127.0.0.1:9789 is refused. Start/pair the runner first, or fall back to Path B (static data) which needs no runner at all — appropriate for environments where you can't run the agent.
Expecting the output to be a token file
By designThe runner returns CSS — the same :root variable + body rule the browser emits — not JSON tokens, Swift, or Kotlin. To get those, transform the CSS (or the static data) yourself; Path B exists precisely because the tool itself only speaks CSS.
Hand-editing the generated CSS
drift / failIf you edit the generated file by hand, the CI drift guard (git diff --exit-code) fails on the next run because the script reproduces the original. Either change the input options/transform, or stop tracking the file as generated — don't hand-patch generated output.
Pinning a single-OS scope for a web token by mistake
Anti-patterntargetOS: "windows" emits a 5-font Windows-only chain. For a public web token use targetOS: "all". Single-OS scopes are correct only for platform-locked outputs (an iOS-only token from ios, a Windows-app token from windows).
Static-data copy drifts from JAD's curated stacks
StalenessPath B copies the lists into your repo, so if JAD later revises a stack (adds a fallback, prepends ui-sans-serif), your copy won't update automatically. Either re-sync periodically, or use Path A (runner) which always reflects the current curated data.
Serif / All literal has unquoted multi-word names
QuirkIf your transform splits the serif / All stack on , , the first two entries (Iowan Old Style, Apple Garamond) are unquoted multi-word names, and "Apple Color Emoji" trails the generic serif. Quote the unquoted names when emitting Swift/Kotlin lists, and don't assume the last entry is the generic family.
Frequently asked questions
How do I generate a system stack without using the browser UI?
POST the options to the local runner: POST http://127.0.0.1:9789/v1/tools/system-font-stack-generator/run with JSON { "sansOrSerif": "sans-serif", "targetOS": "all" }. The runner returns the same CSS the browser tool produces, and it runs locally so nothing is uploaded.
Does the runner endpoint take a file?
No — this tool is file-less. Send a JSON options body with Content-Type: application/json, not a multipart upload. The only two fields are sansOrSerif and targetOS.
What are the exact option values?
sansOrSerif: sans-serif, serif, or monospace (note: sans-serif/monospace, not sans/mono). targetOS: all, mac, windows, linux, ios, or android. Discover them at runtime with GET /api/v1/tools/system-font-stack-generator.
Can I generate sans, serif, and mono in one script?
Yes — loop the three sansOrSerif values through the runner (or your static-data transform) and write three token files. The prebuild-script recipe above does exactly this.
Do I even need the runner if the stacks never change?
No — that's Path B. Because the 18 curated stacks are static, you can commit them as a small data file and generate CSS/Swift/Kotlin from it with no runner. Use the runner (Path A) when you want zero maintenance and always-current curated data.
How do I get Swift or Kotlin tokens, not CSS?
The tool and runner only emit CSS. For native platforms, take the stack value (from the runner CSS or your static data) and transform it — split on , into a [String] for Swift or a listOf(...) for Kotlin. The fan-out recipe above shows this.
How do I keep the generated output from drifting?
Generate into a tracked file and run git diff --exit-code in CI. Generation is deterministic, so a non-empty diff means someone hand-edited the generated file — fail the build and have them change the input instead.
Is the output deterministic enough to cache?
Yes. The same { sansOrSerif, targetOS } always yields the same CSS, so you can cache the result keyed on the options. Nothing in the stacks depends on time, environment, or randomness.
Why does my POST return a Windows-only stack?
You sent targetOS: "windows". For a cross-platform web token use targetOS: "all". Single-OS scopes intentionally trim the chain to one platform's fonts.
Can I strip the body rule and keep only the variable?
Yes. The output contains both a :root { --font-system-… } variable and a body { font-family: var(…) } rule. Regex out the --font-system-…: …; declaration and wrap it in your own :root — the strip recipe above does this in a couple of lines.
Does the runner upload anything?
No. The runner executes on 127.0.0.1:9789 on your machine. For a generative, file-less tool like this there's nothing to upload anyway — it's options in, CSS out, entirely local.
How does this relate to the design-system policy guide?
This guide is the how (generate the values in CI / fan them to platforms); the design-system policy guide is the what and why (pin one canonical token, govern exceptions). Use this to implement the tokens that guide tells you to standardise on.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.