perf(fonts): self-host Outfit and Source Sans 3; fix /river/ contrast - #169
Conversation
…o AA Two /river/ problems flagged earlier and deferred. CLS 0.166 -> 0.014 ------------------ The whole shift was font-swap. Blocking the Google Fonts request took /river/ to exactly 0.000, which is how it was isolated; it was never the hero image, and an earlier claim in #165 that the image dimensions fixed this was wrong. The webfonts load with display=swap and the fallback was materially wider than both, so the swap reflowed headings and paragraphs. /river/ took it as one 0.163 shift (a block growing 266->291px, pushing the hero shot 42px). Fixed with metric-matched fallback faces so the swap is a like-for-like repaint. Two things this needed that are easy to get wrong: - Ratios must be measured from LAYOUT, not canvas TextMetrics. Canvas over a mixed alphanumeric probe gave 98.13% / 89.15%; those values made the homepage WORSE (0.015 -> 0.093), because a fallback 9% too narrow wraps to fewer lines than the real font, so everything below the hero moved DOWN on swap. Laying out a 45-word corpus and reading offsetWidth gives the ratios that actually govern wrapping. - They must be split by weight. Arial has no real 600, so the browser synthesises it and the ratio moves about five points between 400 and 600. One size-adjust per family cannot cover both. No ascent/descent overrides: every line-height here is unitless, so line boxes are font-size x line-height and do not depend on vertical metrics. Median of 3 runs, production as control: before after / 0.0154 0.0025 /river/ 0.1665 0.0144 (light) /river/ 0.1664 0.0146 (dark) /quickstart 0.0041 0.0038 /donate/ 0.0101 0.0113 /donate/ is 0.0012 worse. It is 9x under the "good" threshold either way and I could not attribute it to anything but noise in the same shift, so I am recording it rather than chasing it. Contrast: 4 real failures -> 0 ------------------------------ --rv-ink-faint #6b7a8d measured 4.19:1 behind .rv-brand-tag and 4.38:1 behind .rv-shape-lbl, both 11px text, both under the 4.5:1 AA floor. Darkened to #5f6d7f (~5:1) which is still clearly lighter than --rv-ink-soft, so the three-step ink hierarchy is unchanged. The token has ten call sites and all are text or borders on light surfaces. The other three /river/ "failures" I had been reporting were phantoms of my own checker: it walked past .rv-final-box, which paints a linear-gradient with a transparent background-color, and compared white text against the page ground. Pixel-sampling the rendered element gives 5.95:1 to 7.66:1, so they always passed. Re-run with a checker that bails on background-image rather than guessing: local 0 failures, production 4. Claude-Session: https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2
External review caught that the heavy fallback faces named
src: local('Arial'), which resolves Arial REGULAR. Declaring that resource
as font-weight: 600 700 tells the browser it already IS bold, so it neither
selects a bold face nor synthesises one. Every 600/700 element - the River
wordmark, buttons, strong text, headings - rendered at regular weight for
the whole font-swap window.
My own width check had missed it and looked like a pass: the 400 and 700
widths differed, so "bold renders". They differed only because the two faces
carry different size-adjust values. The direction was the tell and I did not
read it: real Arial goes 575.89 -> 624.45 from 400 to 700 (bold is WIDER),
while the fallback went 571 -> 549 (NARROWER). Dividing the size-adjust back
out gave 549 / 0.9508 = 577.4, which is regular Arial, not bold.
After pointing them at local('Arial Bold') and friends: 571 -> 595, and
595 / 0.9508 = 625.8, matching bold Arial's 624.45. Confirmed visually with
the font request blocked - headings, wordmark and buttons are bold again.
The heavy size-adjust ratios were already measured against bold Arial, so
they are correct now that the face actually serves bold, and CLS is
unchanged: / 0.0013, /river/ 0.0133 light / 0.0141 dark (median of 3).
Claude-Session: https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2
External review finding addressed (922579a)Codex caught a real bug in the fallback faces. Bold was silently lost during the swap windowThe heavy faces named Fixed by naming the bold locals: My own check had passed it, and shouldn't haveI did test bold, and read the result wrong: The widths differed, so my script printed OK. They differed only because the two faces carry different After the fix: Unchanged by the fixThe heavy
Full sweep still clean: no page errors, no transparent text, no horizontal overflow, 0 contrast failures. Codex also independently verified the contrast change: [AI-assisted - Claude] |
…LS entirely
Replaces the metric-matched fallback faces from the previous commits. Those
took /river/ from 0.163 to 0.013, but they were tuning the swap rather than
removing it: the width match drifts +/-4% at body sizes, which left /apps/
at 390px at 0.098, one line-break from failing.
Self-hosting removes the swap. Both files are preloaded so they are in
flight alongside the render-blocking stylesheet, and both faces are
font-display: optional, which has a block period and NO swap period: a face
that has not arrived by first paint is not used for that page load and never
substitutes mid-view. The shift becomes structurally impossible rather than
small.
Measured across 10 pages x 2 widths, median of 5 runs, production as the
before-control:
before after
/river/ 1280 0.1635 0.0000
/ghostkey/ 390 0.1704 0.0000
/bug/ 390 0.1640 0.0000
/about/ 1280 0.1063 0.0000
/quickstart 390 0.0637 0.0000
/try/ 390 0.0477 0.0000
... every other page/width also 0.0000
Worst local CLS across all twenty combinations: 0.0000. Four pages were
above the 0.1 "poor" threshold before.
Both families are VARIABLE fonts, so one file per subset covers 100-900 and
the whole set is four files / 136KB. Verified rather than assumed: the
weight axis gives 4-5 distinct widths from 300 to 800, which also means
weight 800 now works - .content h1 uses it and the old Google request only
asked for 400-700, so it was being synthesised. latin-ext is gated behind
unicode-range and is not fetched by pages that do not need it (confirmed:
it stays "unloaded").
This also removes every third-party request. No Google DNS, TCP, TLS or
font fetch on any page load, confirmed by watching the network on /, /river/
and /apps/.
Both fonts are SIL OFL, which permits redistribution and requires the
licence to travel with them; both licence files are included.
Kept from the deleted fallback work, as comments, because they are the kind
of thing that gets re-introduced:
- src: local('Arial') under a font-weight: 600 700 descriptor tells the
browser that resource already IS bold, so bold silently rendered at
regular weight.
- local() matching is loose: on a Linux box with no Arial, a face whose
only src was local('Arial') bound to Tinos, a SERIF, while carrying a
size-adjust tuned for Arial.
Neither can happen now; nothing depends on what the visitor has installed.
Not changed: layouts/presentations/single.html still loads Inter from
Google. It is a standalone template with its own font and is out of scope
here.
Claude-Session: https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2
Review found a blocker that would have shipped on merge. The @font-face rules used root-absolute url('/fonts/...'), while the preload in head.html uses relURL. Those agree under the ordinary build and disagree under the Freenet contract build (cargo make build-freenet, baseURL /v1/contract/web/<key>/), which prefixes the preload but not the CSS. Nothing rewrites url() inside CSS: scripts/rewrite-paths.py walks only .html and matches only href/src/srcset. Reproduced with a prefixed-baseURL build before fixing: preload: href=/v1/contract/web/TESTKEY123/fonts/outfit-latin.woff2 CSS: url(/fonts/outfit-latin.woff2) <- 404 at the gateway With font-display: optional a 404 is not a slow font, it is PERMANENT system-ui on every load, plus preloaded-and-discarded bytes. publish-freenet.yml runs on every push to main touching hugo-site/**, and does not run on PRs, so CI would not have caught it. Fixed with relative URLs. The stylesheet is always served from <baseURL>/css/, so ../fonts/ resolves correctly under every baseURL with no templating and no build step. Verified end-to-end by serving the contract build under its real path prefix: both faces 200, both usable. The ordinary production build still resolves them too. Also from the same review: - Dropped both latin-ext faces and their files. They are not preloaded, so under font-display: optional they lose the block window and were measured unused on 12/12 cold loads across three network profiles, and the built site contains zero latin-ext characters today - 75KB fetched and discarded. If content ever needs them, the face must be added back AND preloaded. Fonts are now 61KB total. - Source Sans 3's fvar wght axis is 200-900, not the 100-900 declared. Outfit genuinely is 100-900. - OFL-SourceSans3.md renamed .txt so it is served as text. - Corrected the head.html comment: it claimed no Google request on any page load, but layouts/presentations/single.html is a standalone layout that still loads Inter from Google. Two things recorded rather than changed: - Headings now render at genuine weight 800 (+1.22% width, measured on /about/ at 64px: 574 -> 581). Production clamped 800 to 700 because the Google request only asked for 400-700; .title and .content h2-h6 ask for 800 and now get it. - The Source Sans 3 file is Google's subset, which under OFL is a Modified Version bearing the Reserved Font Name "Source". Adobe ships only 150KB statics per weight, so using originals would be ~750KB against 29KB and would defeat this change. Outfit has no reserved name and is unaffected. Claude-Session: https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2
Review findings addressed (9300a00)The High-severity finding was real, reproducible, and would have shipped on merge. Thank you — this one was well caught. The blocker
And the consequence is worse than a slow font: with Fixed with relative The ordinary production build still resolves them too. Also fixed
Recorded rather than changed, with reasoningHeading weight 800. Confirmed your measurement independently ( Source Sans 3 RFN. Genuine point, and the mitigation is worse than the problem: Adobe's release ships only 150KB static OTFs per weight — no variable woff2 — so shipping Originals would be roughly 750KB against 29KB and would defeat this PR. Documented as a known residual. Outfit has no reserved name and is clean. On your refutation of the
|
Fixes the two
/river/problems deferred from #164/#165 — and, along the way, replaces the approach this PR started with.What changed, in the end
Self-hosted Outfit and Source Sans 3, preloaded, with
font-display: optional. That removes font-swap layout shift entirely instead of tuning it.The PR began as metric-matched fallback
@font-facerules (size-adjusttuned to the webfont's width). That got/river/from 0.163 to 0.013, but it was making the swap cheap rather than removing it: the width match drifts ±4% at body sizes, which left/apps/at 390px at 0.098 — one line-break from failing — and it carried its own hazards. All of it is deleted; the post-mortem is kept as comments inbase.cssbecause both traps are easy to reintroduce.CLS: every page, both widths, exactly 0.0000
Median of 5, cold context per run, production as the before-control:
/river//ghostkey//bug//about//try//donate///apps/,/quickstart/,/faq/Four pages were above the 0.1 "poor" threshold. Re-confirmed under Fast 3G and Slow 3G on a cold cache, so the zero is not a localhost artifact.
font-display: optionaldoes not cost the webfont on slow connections. That was the main risk and it was measured: 10/10 cold loads render in the real fonts at Slow 3G (400kb, 2000ms RTT), because the preload puts the font in flight beside the render-blocking stylesheet and throttling slows both equally. The real margin is ~100–150ms of lateness relative to the rest of the page, which is why the 404 bug below was so serious.Contrast: 4 real failures → 0
--rv-ink-faint#6b7a8dwas 4.19:1 behind.rv-brand-tagand 4.38:1 behind.rv-shape-lbl— 11px text, under the 4.5:1 AA floor. Now#5f6d7f: 5.28:1 on white, 4.77:1 on--rv-band, still clearly lighter than--rv-ink-softso the ink hierarchy holds. Dark's#93a2b3already passed and is unchanged.I had been reporting 7 failures on
/river/; three were phantoms of my own checker walking past alinear-gradientand comparing white text to the page ground. Pixel-sampled, they measure 6.3–7.4:1 and always passed. Honest count is 4 → 0.A blocker caught in review
The
@font-faceURLs were root-absolute while the preload usedrelURL. Under the Freenet contract build those disagree — the preload gets the/v1/contract/web/<key>/prefix, the CSS does not — so every face 404s. Withoptional, a 404 is permanent system-ui, not a slow font, andpublish-freenet.ymlruns on every push to main but not on PRs, so nothing would have caught it.Fixed with relative
../fonts/URLs, which resolve against the stylesheet's own location and are correct under any baseURL. Verified by serving the contract build under its real path prefix.Other review outcomes
optionalguaranteed they were never used — measured 0/12 cold — and the built site has zero latin-ext characters. Fonts are now 61KB total, four files, both variable.200 900axis..mdlicence renamed.txt; both SIL OFL licences included as OFL requires.layouts/presentations/single.htmlis standalone and still loads Inter; out of scope.)Recorded, not changed
.titleand.content h2–h6ask for 800 and now get it..content h1is no longer synthesised — the old request topped out at 700.[AI-assisted - Claude]
https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2