How to wire kerning audits into your design system ci
- Step 1Decide whether a count check is meaningful for your fonts — Run each font through the [Kerning Pair Auditor](/font-tools/kerning-pair-auditor) once. If `total_kerning_pairs` is a healthy number, the font is kern-table-based and the check works. If it's zero, the font is GPOS-kerned — a count check will always read zero and catch nothing. Don't add a check that measures an empty table.
- Step 2Capture a baseline count per font — For each kern-table font, record its non-zero pair count in a committed file like `fonts.kerning-baseline.json`. This is the same number the auditor's metrics strip shows as 'Total pairs'. Commit it so CI has something to compare against.
- Step 3Recompute the count on every build — In CI, parse each font with opentype.js and count non-zero `font.kerningPairs` entries — or POST the font to the local runner's `kerning-pair-auditor` endpoint and read `total_kerning_pairs` from the JSON. Do this AFTER any subsetting/optimisation step, so you measure what actually ships.
- Step 4Compare against baseline with percentage thresholds — Compute the percentage drop from baseline. A small drop (under ~10%) is usually noise; a large drop (over ~30%, or to zero) almost always means a build step stripped kerning. Warn on the small drop, fail the build on the large one.
- Step 5Update the baseline deliberately — When a font update legitimately changes the count (foundry re-cut, intentional re-subset), update `fonts.kerning-baseline.json` in the same PR that changes the font. This forces a human to acknowledge the change rather than letting it slip through as 'noise'.
- Step 6Optionally check specific critical pairs — Beyond the count, assert that named critical pairs exist. Look up each pair's glyph indices via the cmap, then check `font.kerningPairs` for that key (or scan the auditor JSON `sample` strings). Fail if a brand-critical pair vanished even when the overall count looks fine.
Two ways to run the audit in CI
Direct opentype.js vs the JAD runner HTTP API. Both produce the same kern-table-only pair count. The runner mirrors the browser tool's behaviour exactly.
| Approach | How it works | When to use it |
|---|---|---|
| Direct opentype.js | npm i opentype.js, opentype.parse(buf), count non-zero font.kerningPairs | You want zero external services and full control of the count logic |
| JAD runner HTTP API | POST the font as multipart files to http://127.0.0.1:9789/v1/tools/kerning-pair-auditor/run, read total_kerning_pairs from the JSON | You'd rather call one endpoint and get the exact same result the browser tool produces, font stays on your machine |
| Schema discovery | GET /api/v1/tools/kerning-pair-auditor returns the one option: pairLimit (range 10–500, default 50) | Programmatically confirm the only knob before scripting against it |
Threshold guidance for the count check
Percentage thresholds, not absolute, because baselines vary by orders of magnitude. These are starting points — tune per design system.
| Baseline count | Noise band | Warn at | Fail at |
|---|---|---|---|
| ~100 pairs | ±5 pairs | drop > 10% | drop > 30% or to 0 |
| ~500 pairs | ±25 pairs | drop > 10% | drop > 30% or to 0 |
| ~2,000 pairs | ±100 pairs | drop > 10% | drop > 30% or to 0 |
| 0 pairs (GPOS font) | n/a | n/a — count check is meaningless | n/a — use a visual or fontTools GPOS check instead |
Cookbook
Working snippets for a kern-count CI gate. The recurring caveat: this counts the legacy kern table only, so guard it with a 'is this font even kern-table-based?' check first. For GPOS fonts, see the kern vs GPOS explainer.
Count non-zero kern-table pairs with opentype.js
ExampleThe core of the CI check, mirroring exactly what the browser tool does: parse, read font.kerningPairs, drop zero values, count. This is the legacy kern table only.
// count-kern.mjs
import opentype from 'opentype.js';
import { readFileSync } from 'node:fs';
function kernCount(path) {
const font = opentype.parse(readFileSync(path).buffer);
const pairs = font.kerningPairs ?? {}; // kern TABLE only
let n = 0;
for (const k of Object.keys(pairs)) {
if (pairs[k] !== 0) n++; // drop zero-value pairs
}
return n;
}
console.log(kernCount(process.argv[2]));
// Note: WOFF2 must be decompressed first — opentype.parse
// expects raw SFNT/TTF/OTF bytes, not a WOFF2 wrapper.Baseline-and-compare CI gate
ExampleRead the committed baseline, recompute, and exit non-zero on a large drop. Percentage thresholds so it works across fonts of different sizes.
// kern-gate.mjs
import { readFileSync } from 'node:fs';
import { kernCount } from './count-kern.mjs';
const base = JSON.parse(readFileSync('fonts.kerning-baseline.json'));
let failed = false;
for (const [path, baseline] of Object.entries(base)) {
const now = kernCount(path);
const dropPct = baseline ? (baseline - now) / baseline * 100 : 0;
if (now === 0 && baseline > 0) {
console.error(`FAIL ${path}: kerning vanished (was ${baseline})`);
failed = true;
} else if (dropPct > 30) {
console.error(`FAIL ${path}: ${dropPct.toFixed(0)}% drop`);
failed = true;
} else if (dropPct > 10) {
console.warn(`WARN ${path}: ${dropPct.toFixed(0)}% drop`);
}
}
process.exit(failed ? 1 : 0);Run via the JAD local runner HTTP API
ExampleInstead of bundling opentype.js, POST the font to the paired local runner and read the same JSON the browser tool emits. The font never leaves your machine. pairLimit is the only option; for a count check you only need total_kerning_pairs, which is independent of pairLimit.
# Discover the schema (the one option: pairLimit)
curl http://127.0.0.1:9789/../ # GET /api/v1/tools/kerning-pair-auditor
# Run the audit on a font
curl -s -X POST \
-F 'files=@dist/fonts/BrandSans.ttf' \
http://127.0.0.1:9789/v1/tools/kerning-pair-auditor/run \
| node -e 'const d=JSON.parse(require("fs").readFileSync(0));\
console.log(JSON.parse(d.content).total_kerning_pairs)'
# total_kerning_pairs is the full non-zero count regardless
# of pairLimit (pairLimit only caps the returned pairs array).Guard the check: skip GPOS-only fonts
ExampleBefore trusting a count, verify the font is kern-table-based. If its baseline is zero, a count check measures nothing — skip it and rely on a visual/GPOS review instead.
for (const [path, baseline] of Object.entries(base)) {
if (baseline === 0) {
console.log(`SKIP ${path}: GPOS-kerned, count check N/A`);
continue; // count of 0 is permanent; nothing to detect
}
// ... run the count comparison ...
}
// A baseline of 0 means kerning lives in GPOS. The count check
// cannot see it. Use desktop fontTools (ttx -t GPOS) or a
// visual render diff for those fonts instead.Catch a subsetter that dropped kerning
ExampleThe most valuable thing this check actually finds: a build step that stripped layout tables. JAD's in-browser font-subsetter uses opentype.js and drops GPOS/GSUB — run the count AFTER subsetting to catch it.
# Pre-subset: BrandSans.ttf → kernCount 842 # Post-subset: BrandSans.subset.ttf → kernCount 842 (kern TABLE survived) # ... but if the source kerned via GPOS: # Pre-subset: total_kerning_pairs 0 (GPOS, invisible to count) # Post-subset: total_kerning_pairs 0 (GPOS now DROPPED by opentype.js subsetter) # → count is 0 both times; the regression is INVISIBLE to a count check. # # Lesson: count checks catch kern-TABLE drops. For GPOS-kerned # fonts, use a layout-preserving subsetter (hb-subset/pyftsubset) # and verify the GPOS kern feature survived with fontTools.
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.
Count check is green but measures nothing
Silent no-opIf a font is GPOS-kerned, font.kerningPairs is permanently empty (zero), so the count never changes and the check passes forever while detecting nothing. Always verify a non-zero baseline before relying on the gate. A baseline of 0 means 'skip this font' — the count check is structurally blind to GPOS kerning.
opentype.js can't parse WOFF2 directly
Error if not decompressedopentype.parse expects raw SFNT/TTF/OTF bytes. A WOFF2 buffer fails to parse. Decompress first (the browser tool uses a WASM wawoff2 build; in Node use the wawoff2 package or woff2_decompress). The runner endpoint handles this for you — POST the WOFF2 and it decompresses internally.
A layout-preserving subsetter changes the GPOS but not the count
Invisible to count checkIf you switch to hb-subset/pyftsubset (which preserve GPOS), a GPOS kern regression won't show in the kern-table count because the count was zero to begin with. To gate GPOS kerning you need a fontTools-based check on the GPOS kern feature, not a font.kerningPairs count. The count gate only protects legacy-table kerning.
Absolute thresholds misfire across fonts
Tuning errorAn absolute threshold like 'fail on drop > 50 pairs' is noise on a 2,000-pair font and over-strict on a 100-pair font. Use percentage thresholds (warn > 10%, fail > 30%) so one rule works across a design system with mixed baselines. The threshold table above is a starting point.
Baseline not updated after a legitimate change
False positiveA deliberate foundry re-cut that reduces pair count will fail the gate until you update fonts.kerning-baseline.json. That's the intended behaviour — it forces explicit human acknowledgement. Update the baseline in the same PR as the font change so the diff documents the decision.
Critical pair gone but count unchanged
Count check misses itA font could drop one brand-critical pair while adding others, leaving the total count flat. A count-only gate won't catch this. Add an explicit critical-pairs assertion (look up glyph indices via cmap, check the key exists in font.kerningPairs) for pairs you can't afford to lose.
Font version bumped but bytes unchanged
Expected no-opThe count is a pure function of the font bytes, so a version-string bump with no glyph/table change produces an identical count and the gate stays green. This is correct — reruns on unchanged fonts are idempotent. The gate reacts to table content, not metadata.
Runner not paired in CI
Connection errorThe HTTP-API approach needs the @jadapps/runner running and paired on the CI machine at 127.0.0.1:9789. If it isn't, the POST fails with connection refused. For CI where you can't run the runner, use the direct opentype.js approach instead — it needs only npm i opentype.js and a WOFF2 decompressor.
Frequently asked questions
Does the CI check catch GPOS kerning regressions?
No. It counts font.kerningPairs, which opentype.js fills from the legacy kern table only — GPOS pair-pos and class-based kerning aren't enumerated. For a GPOS-kerned font the count is permanently zero, so the gate detects nothing. To guard GPOS kerning you need a fontTools-based check on the GPOS kern feature, or a visual render diff. The count gate protects legacy-table kerning specifically.
What does the count actually measure?
The number of non-zero pairs in the legacy kern table — the exact same number the browser tool reports as total_kerning_pairs. Zero-value pairs are filtered out. It's a coverage proxy for kern-table fonts and meaningless (always zero) for GPOS-only fonts.
Should I use opentype.js directly or the runner API?
Direct opentype.js if you want no external service — npm i opentype.js, parse, count. The runner API if you'd rather POST the font to http://127.0.0.1:9789/v1/tools/kerning-pair-auditor/run and read the exact JSON the browser tool emits, with the font staying on your machine. Both give the same count; the runner just handles WOFF2 decompression and formatting for you.
What thresholds should I set?
Percentage-based, not absolute: warn on a drop over ~10%, fail on a drop over ~30% or to zero. Absolute numbers misfire across fonts with different baselines (±50 pairs is noise on a 2,000-pair font, catastrophic on a 100-pair one). Tune per design system, but percentages survive a mixed-font repo far better.
Why run the count after subsetting?
Because the subsetter is the most likely thing to drop kerning. JAD's in-browser font-subsetter uses opentype.js, which drops GPOS/GSUB layout tables — so a font subsetted through it loses GPOS kerning entirely. Counting the post-build artefact catches what actually ships, not the foundry's original. For GPOS-kerned fonts, use a layout-preserving engine (hb-subset/pyftsubset).
How do I check that a specific critical pair still exists?
Look up the two glyphs' indices via the font's cmap, build the key "${left},${right}", and check font.kerningPairs[key] is present and non-zero. Or run the auditor at Top-N 500 and scan the JSON sample strings for the pair. Add this as a separate assertion — a count-only gate can miss a single critical pair vanishing while the total stays flat.
Can opentype.js read a WOFF2 file in Node?
Not directly — opentype.parse needs raw SFNT/TTF/OTF bytes. Decompress the WOFF2 first with the wawoff2 npm package or woff2_decompress, then parse. The runner endpoint handles WOFF2 internally, so if you POST to the runner you skip this step. The browser tool uses a WASM wawoff2 build for the same reason.
Is the font uploaded to JAD's servers in the runner approach?
No. The @jadapps/runner runs the same processor locally on your machine and returns the result over 127.0.0.1. Font bytes never reach JAD's servers — the runner is just a local bridge that lets a script call the tool without the browser UI. This is the same privacy property as the in-browser tool.
What's the one option the tool exposes?
pairLimit — a range from 10 to 500, default 50, that caps how many pairs the output pairs array contains. For a CI count check you don't need it: total_kerning_pairs is the full non-zero count regardless of pairLimit. GET /api/v1/tools/kerning-pair-auditor confirms this is the only knob.
Will the check produce false positives?
Yes, intentionally, when a font legitimately changes — a foundry re-cut that reduces pairs fails the gate until you update the baseline. That's the design: it forces a human to acknowledge the change in the PR rather than letting a real regression slip through disguised as 'expected'. Update fonts.kerning-baseline.json alongside the font change.
Can I run this entirely offline?
Yes. Direct opentype.js needs only the npm package and a WOFF2 decompressor — no network. The runner approach is also local (everything stays on 127.0.0.1). Either way, no font bytes leave the CI machine, which matters for licensed fonts whose terms restrict redistribution.
How is this different from the in-browser auditor?
Same underlying logic and same kern-table-only limitation — the in-browser Kerning Pair Auditor is for a quick one-off look, while this guide is about automating the count in CI so regressions fail the build. Use the browser tool to establish your baseline numbers, then script the comparison with opentype.js or the runner.
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.