Inconsistent font rendering in the Tauri desktop app on Arch Linux:
- Irregular random gaps between letters (uneven tracking)
- Small/weird letters at different sizes/weights appearing mid-word
- Layout shift after font loads
- OS: Arch Linux
- Desktop app: Tauri (uses WebKitGTK 4.1 webview, NOT Chromium)
- Rendering stack: WebKitGTK → Cairo → FreeType → HarfBuzz
- Font: Anthropic Sans Web Text (variable font, woff2, 115KB)
- Font axes:
wght300-800 (default 400),opsz16-48 (default 16)
WebKitGTK has unreliable variable-font support:
format('woff2-variations')inconsistently honoredfont-variation-settingsforopszaxis causes per-glyph interpolation bugs- Produces uneven tracking and mid-word weight/size jumps
Fix: Generated four static per-weight woff2 files using fontTools instantiateVariableFont, each pinned at opsz=16 (Text master). Total bundle dropped from 115KB to ~91KB.
User's ~/.config/fontconfig/fonts.conf had strong-binding aliases:
<match target="pattern">
<test qual="any" name="family"><string>Inter</string></test>
<edit name="family" mode="assign" binding="strong"><string>Geist</string></edit>
</match>
<match target="pattern">
<test qual="any" name="family"><string>system-ui</string></test>
<edit name="family" mode="assign" binding="strong"><string>Geist</string></edit>
</match>WebKitGTK queries fontconfig for fallback glyphs. Any glyph that fell through to Inter or system-ui in the CSS fallback chain was silently replaced with Geist (completely different metrics), causing mid-word size/weight jumps.
Fix: Removed Inter, Ubuntu, Segoe UI, system-ui, -apple-system, and generic sans-serif from the CSS fallback chain. Final chain: 'Anthropic Sans', 'Cantarell', 'Noto Sans', 'DejaVu Sans'.
tailwind.config.ts had:
fontFamily: {
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
ui: ["var(--font-ui)", "system-ui", "sans-serif"],
}Tailwind's font-sans utility is applied by Tailwind preflight to body and used by virtually every component. This appended system-ui to the computed font-family on every element, bypassing the cleaned --font-sans CSS variable entirely. The fontconfig system-ui → Geist rewrite then kicked in.
Fix: Changed to:
fontFamily: {
sans: ["var(--font-sans)"],
ui: ["var(--font-ui)"],
mono: ["var(--font-mono)"],
}The CSS variable already contains the full safe fallback chain. Tailwind just needs to reference it without adding its own fallbacks.
The generated per-weight files were static in outline data, but not static in identity metadata:
- all four files still exposed internal names like
Anthropic Sans Web Text Regular - all four shared the same unique/PostScript names:
AnthropicSansWebVariable-TextRegular - all four kept
nameID 25(AnthropicSansWebVariable) - all four kept a
STATtable withwght,opsz, anditalaxes - non-regular weights kept the OS/2
Regularselection bit instead of weight-specific selection bits
That means the CSS descriptors said "400/500/600/700", while the font files still told the lower stack "same variable Text Regular face". On WebKitGTK/Cairo/fontconfig this can collide in face registration, fallback matching, or cache selection below CSS and show up as random weight/size changes.
Fix: normalized each bundled WOFF2 into a plain static face:
- stripped
STATand any variable/axis metadata tables - set family/subfamily/full/PostScript names to
Anthropic Sans Regular|Medium|SemiBold|Bold - set unique IDs to
OpenLinear;AnthropicSans-{Style};static-opsz16-wght{weight} - removed variation PostScript prefix
nameID 25 - corrected OS/2
usWeightClass,fsSelection, andhead.macStyle - updated
scripts/build-static-fonts.shso future regenerated fonts get the same cleanup
| Fix | File | What |
|---|---|---|
font-display: block |
globals.css | Prevents FOUT reflow (fallback metrics differ wildly) |
Dropped text-rendering: optimizeLegibility |
globals.css | Triggers FreeType slow path, bad with variable fonts |
Added font-synthesis: none |
globals.css | Prevents browser from fake-bolding fallback during load |
Dropped font-feature-settings: "calt" 0 |
globals.css | Was disabling contextual alternates (FreeType honors literally) |
Added font-variant-ligatures: contextual |
globals.css | Keeps contextual alternates enabled for proper kerning |
Added unicode-range to @font-face |
globals.css | Prevents webkit from querying fontconfig for glyphs the woff2 covers |
| Cache clear in start script | start-prod-preview.sh | rm -rf .next out before build so CSS changes always take effect |
| Static metadata normalization | fonts + build-static-fonts.sh | Makes each WOFF2 a distinct non-variable face all the way down |
| Font regression verifier | scripts/verify-desktop-fonts.js | Fails if axis tables, stale variable names, wrong weights, or dangerous active fallbacks return |
| Turbopack root pin | desktop-ui/next.config.js | Keeps CSS/font builds rooted in the repo instead of parent /home/kaizen lockfiles |
| Font URL cache busting | globals.css | Forces WebKitGTK to fetch the normalized WOFF2 files instead of reusing stale cache records |
| Tauri dev URL cache busting | start-prod-preview.sh | Prevents the preview webview from reusing an old cached app shell |
323bfb6— font-display block, opsz pin, cache clear in start scriptf47b301— static per-weight font masters for WebKitGTK998fa0b— bypass fontconfig family aliases in globals.css48a0157— drop tailwind fontFamily system-ui fallback (final fix)
Current hardening pass after that commit:
- normalized the four committed WOFF2 files
- hardened
scripts/build-static-fonts.sh - added
pnpm --filter @openlinear/desktop-ui testas the font regression check - pinned the desktop UI Turbopack root to the repo
- cache-busted the font URLs and Tauri preview URL
On Linux with WebKitGTK, the font rendering pipeline is:
CSS font-family → WebKitGTK → fontconfig pattern matching → FreeType rasterization
fontconfig's pattern matching can silently rewrite font names before they reach FreeType. Any font name in the CSS fallback chain that matches a user's fontconfig alias will be substituted with a completely different font, potentially mid-word if the primary font doesn't cover certain glyphs or during the font-load period.
The safe approach for Tauri/WebKitGTK apps:
- Use static fonts (not variable) to avoid axis interpolation bugs
- "Static" must include metadata: no
STAT/axis tables, no variable PostScript prefix, no shared Regular names across weights
- "Static" must include metadata: no
- Keep the CSS fallback chain minimal — only fonts you know aren't commonly aliased, and do not use generic
sans-serifwhen user fontconfig aliases it - Don't duplicate fallback names in both CSS variables AND Tailwind config
- Add
unicode-rangeto @font-face to prevent unnecessary fontconfig queries - Keep an automated font artifact check; visual symptoms appear too late and are too stack-dependent
Commands run after the hardening pass:
pnpm --filter @openlinear/desktop-ui test- validates all four WOFF2 files have no
STAT/variable tables - validates names, PostScript IDs, OS/2 weights, and selection bits
- validates active desktop font config does not reintroduce
system-ui,Inter,Geist,Segoe UI,Ubuntu, or-apple-system
- validates all four WOFF2 files have no
pnpm --filter @openlinear/desktop-ui typecheckBUILD_FOR_TAURI=1 pnpm --filter @openlinear/desktop-ui buildfc-scan apps/desktop-ui/public/fonts/*.woff2- now reports family
Anthropic Sans - styles
Regular,Medium,SemiBold,Bold variable=Falsefor all four files
- now reports family