Skip to content

perf(fonts): self-host Outfit and Source Sans 3; fix /river/ contrast - #169

Merged
sanity merged 4 commits into
mainfrom
river-contrast-cls
Sep 4, 2026
Merged

perf(fonts): self-host Outfit and Source Sans 3; fix /river/ contrast#169
sanity merged 4 commits into
mainfrom
river-contrast-cls

Conversation

@sanity

@sanity sanity commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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-face rules (size-adjust tuned 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 in base.css because 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:

page before 390 before 1280 after (both)
/river/ 0.0078 0.1697 0.0000
/ghostkey/ 0.1770 0.1469 0.0000
/bug/ 0.1711 0.0370 0.0000
/about/ 0.0673 0.0929 0.0000
/try/ 0.0484 0.0401 0.0000
/donate/ 0.0263 0.0095 0.0000
/ 0.0019 0.0154 0.0000
/apps/, /quickstart/, /faq/ 0.0000

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: optional does 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 #6b7a8d was 4.19:1 behind .rv-brand-tag and 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-soft so the ink hierarchy holds. Dark's #93a2b3 already passed and is unchanged.

I had been reporting 7 failures on /river/; three were phantoms of my own checker walking past a linear-gradient and 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-face URLs were root-absolute while the preload used relURL. Under the Freenet contract build those disagree — the preload gets the /v1/contract/web/<key>/ prefix, the CSS does not — so every face 404s. With optional, a 404 is permanent system-ui, not a slow font, and publish-freenet.yml runs 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

  • Dropped both latin-ext faces (−75KB). Not preloaded, so optional guaranteed 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.
  • Source Sans 3's declared range corrected to its real 200 900 axis.
  • .md licence renamed .txt; both SIL OFL licences included as OFL requires.
  • Zero third-party requests from the main layout — no Google DNS/TCP/TLS on any page load. (layouts/presentations/single.html is standalone and still loads Inter; out of scope.)

Recorded, not changed

  • Headings now render at genuine weight 800 (+1.22%: 574→581 at 64px). Production clamped 800→700 because the Google request only asked 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 originals would be ~750KB against 29KB. Outfit has no reserved name.
  • Weight 800 also means .content h1 is no longer synthesised — the old request topped out at 700.

[AI-assisted - Claude]

https://claude.ai/code/session_01Cwq3ZRYFqSW9rMtGJYqfo2

…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
@sanity

sanity commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

External review finding addressed (922579a)

Codex caught a real bug in the fallback faces.

Bold was silently lost during the swap window

The heavy 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 — would render at regular weight for the entire font-swap window.

Fixed by naming the bold locals: local('Arial Bold'), local('Arial-BoldMT'), local('Liberation Sans Bold'), local('Helvetica Bold').

My own check had passed it, and shouldn't have

I did test bold, and read the result wrong:

"Outfit Fallback"   400=571  700=549   <- I read this as "bold renders"
Arial               400=576  700=624

The widths differed, so my script printed OK. They differed only because the two faces carry different size-adjust values. The direction was the tell: real Arial gets wider from 400 to 700 (576 → 624), while my fallback got narrower (571 → 549). Dividing the size-adjust back out: 549 / 0.9508 = 577.4 — regular Arial, not bold.

After the fix: 571 → 595, and 595 / 0.9508 = 625.8, matching bold Arial's 624.45. Also confirmed visually with the font request blocked — headings, wordmark and buttons are bold again.

Unchanged by the fix

The heavy size-adjust ratios were already measured against bold Arial (the calibration set font-weight: 600/700 on family Arial, which selects bold), so they are correct now that the face actually serves bold. CLS is unaffected:

page after fix (median of 3)
/ 0.0013
/river/ light 0.0133
/river/ dark 0.0141
/quickstart/ 0.0037
/donate/ 0.0112

Full sweep still clean: no page errors, no transparent text, no horizontal overflow, 0 contrast failures.

Codex also independently verified the contrast change: #5f6d7f gives 5.28:1 on white, 5.04:1 on the page ground and 4.77:1 on --rv-band #eef4fb, against the old #6b7a8d at 4.38 / 3.96. All above the 4.5 floor on the surfaces it actually lands on.

[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
@sanity sanity changed the title fix(river): metric-matched font fallbacks, and darken the faint ink to AA perf(fonts): self-host Outfit and Source Sans 3; fix /river/ contrast Sep 4, 2026
@sanity

sanity commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

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

@font-face used root-absolute url('/fonts/…') while the preload used relURL. Those agree under the ordinary build and disagree under the contract build. Reproduced before fixing:

preload:  href=/v1/contract/web/TESTKEY123/fonts/outfit-latin.woff2
CSS:      url(/fonts/outfit-latin.woff2)          <- 404 at the gateway

And the consequence is worse than a slow font: with font-display: optional a 404 means the face is never used, so it is permanent system-ui on every load, forever, plus ~61KB preloaded and discarded. publish-freenet.yml runs on every push to main touching hugo-site/** and does not run on PRs, so nothing would have flagged it.

Fixed with relative ../fonts/ URLs rather than templating or extending rewrite-paths.py. The stylesheet is always served from <baseURL>/css/, so ../fonts/ is correct under every baseURL with no build step and nothing to keep in sync. Verified end-to-end by serving the contract build under its real path prefix:

contract/        fonts=[(200,'outfit-latin.woff2'), (200,'source-sans-3-latin.woff2')]  usable={outfit:True, ss3:True}
contract/river/  fonts=[(200,'outfit-latin.woff2'), (200,'source-sans-3-latin.woff2')]  usable={outfit:True, ss3:True}

The ordinary production build still resolves them too.

Also fixed

  • Dropped both latin-ext faces and files. Your measurement was decisive: not preloaded → loses the optional block window → 0/12 cold loads. Combined with zero latin-ext characters in the built site, that was 75KB fetched and discarded. Fonts are now 61KB, four files. The comment records that adding one back requires preloading it too.
  • Source Sans 3 weight range → 200 900, matching its actual fvar axis. Outfit genuinely is 100 900.
  • .md.txt for the licence.
  • Corrected the head.html comment. "No request to Google on any page load" was false — layouts/presentations/single.html is standalone and still loads Inter.
  • PR title and body rewritten. You were right that they described the deleted approach; the body now documents self-hosting, optional, the binaries, the licences, and the correct 0.0000 numbers.

Recorded rather than changed, with reasoning

Heading weight 800. Confirmed your measurement independently (/about/ at 64px: production 574/574 clamped, branch 574/581, +1.22%). .title and .content h2–h6 ask for 800 and now get it, so this is the stylesheet being honoured rather than a new design decision. Flagged in the PR body so it is not a surprise.

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 optional risk

Worth stating plainly since it was my main worry going in: your Slow 3G measurement (10/10 render in the real fonts) and the delay-isolation showing the cliff at ~100–150ms of relative lateness is the clearest characterisation of this design in the whole thread. It is also exactly why the 404 was catastrophic rather than cosmetic.

I did not add the font regression test you suggested. It is the right idea — the failure is silent and produces no CLS signal — but it belongs with the check-links tooling rather than bolted onto this change.

[AI-assisted - Claude]

@sanity
sanity merged commit c00fa5b into main Sep 4, 2026
3 checks passed
@sanity
sanity deleted the river-contrast-cls branch September 4, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant