Skip to content

docs: self-host fonts, eliminate layout shift, add SPA navigation#643

Merged
tony merged 22 commits intomasterfrom
docs-fonts
Mar 14, 2026
Merged

docs: self-host fonts, eliminate layout shift, add SPA navigation#643
tony merged 22 commits intomasterfrom
docs-fonts

Conversation

@tony
Copy link
Member

@tony tony commented Mar 14, 2026

Summary

Port documentation frontend improvements from tmuxp:

Overhaul the docs frontend for faster, smoother page loads:

  • Self-hosted fonts — IBM Plex Sans/Mono via Fontsource CDN with build-time download, caching, and preload to eliminate Flash of Unstyled Text (FOUT)
  • Font fallback metrics — Capsize-derived size-adjust, ascent-override, descent-override on system fallbacks so text does not reflow when web fonts load
  • Layout shift prevention — badge placeholder sizing and sidebar logo dimensions to eliminate Cumulative Layout Shift (CLS)
  • SPA-like navigation — vanilla JS script (~170 lines, no deps) intercepts internal link clicks and swaps only the three DOM regions that change
  • View Transitions API — smooth crossfade between pages during SPA navigation (instant swap on unsupported browsers)
  • Typography refinements — kerning, ligatures, letter-spacing on body; optimizeSpeed on code blocks; Biome-inspired heading scale; wider TOC panel
  • Sidebar visibility gate — prevent flash of active link state on initial load

See tmuxp PRs for full design rationale and test plan.

tony added 4 commits March 14, 2026 06:51
why: Furo's default TOC title (10px) is nearly invisible and smaller
than its own items (12px), inverting typographic hierarchy. Body
line-height (1.5) is tighter than WCAG-recommended range.
what:
- Bump TOC item size 75% → 81.25% (12→13px) via --toc-font-size
- Bump TOC title size 62.5% → 87.5% (10→14px) via --toc-title-font-size
- Increase .toc-tree line-height 1.3 → 1.4 for wrapped entries
- Increase article line-height 1.5 → 1.6 for paragraph readability
- Enable text-rendering: optimizeLegibility on body
why: Furo hardcodes .toc-drawer to 15em; long TOC entries overflow
and code blocks in the content area are cramped.
what:
- Override .toc-drawer min-width to 18em with flex-shrink: 0
- Move padding to .toc-sticky inner panel (1.5em right)
- Set .content to flex: 1 1 46em with max-width: 46em
- Override right offset at ≤82em breakpoint for collapse
why: 81.25% (13px) is still noticeably smaller than the 14px body
text; at 87.5% (14px) the TOC matches body size and reads
comfortably beside it.
what:
- Change --toc-font-size from --font-size--small--2 to --font-size--small
- Move variables from :root to body for specificity with Furo
…brow labels

why: Furo headings are large and bold, crowding the page and
flattening visual hierarchy. Biome-inspired medium-weight scale
uses size and spacing — not boldness — to convey structure.
what:
- Set all article headings to font-weight: 500
- Scale: h1 1.8em, h2 1.6em, h3 1.15em, h4-h6 eyebrow treatment
- Add uppercase + letter-spacing + muted color for h4-h6
- Add changelog heading extras for #history section
- Revert TOC variables from body back to :root
@codecov
Copy link

codecov bot commented Mar 14, 2026

Codecov Report

❌ Patch coverage is 99.55947% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 51.19%. Comparing base (372bfb1) to head (7a7e83b).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
tests/docs/_ext/conftest.py 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #643      +/-   ##
==========================================
+ Coverage   46.55%   51.19%   +4.64%     
==========================================
  Files          22       25       +3     
  Lines        2363     2590     +227     
  Branches      389      402      +13     
==========================================
+ Hits         1100     1326     +226     
  Misses       1094     1094              
- Partials      169      170       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
Copy link
Member Author

tony commented Mar 14, 2026

Code review

Found 2 issues. Checked for bugs and CLAUDE.md compliance.


Issue 1 — Bug: font_faces entry appended even when font download fails (score: 90)

if _download_font(url, cached):
shutil.copy2(cached, fonts_dir / filename)
font_faces.append(
{
"family": font["family"],
"style": style,
"weight": str(weight),
"filename": filename,
}
)

font_faces.append(...) is outside the if _download_font(url, cached): block — it runs unconditionally whether the download succeeded or not. When a font fails to download (network offline, CDN error), the @font-face rule is still emitted referencing a .woff2 file that was never written to _static/fonts/. The browser will 404 on every font request and fall back to system fonts, defeating the purpose of the extension.

Fix: indent the font_faces.append(...) call one level to place it inside the if block, so only successfully-obtained fonts are registered.


Issue 2 — CI: docs-fonts branch trigger not reverted (score: 82)

- master
- docs-fonts

Commit 7b789ec9 adds docs-fonts to the push.branches trigger with the message "temporarily add docs-fonts branch to trigger". The corresponding tmuxp PR (which this is ported from) includes a revert commit that removes it before merging. This PR does not include that revert, so after merging the docs-fonts trigger will remain permanently. Any future branch named docs-fonts (or the current one if not deleted) will continue to trigger a full docs rebuild and deploy to production.

Fix: add a revert commit (or amend the workflow) to remove docs-fonts from push.branches before merging.

🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 12 commits March 14, 2026 15:17
why: Standardize on IBM Plex Sans / Mono across projects without
committing ~227KB of binary font files to the repo.
what:
- Add sphinx_fonts extension that downloads fonts at build time,
  caches in ~/.cache/sphinx-fonts/, and generates @font-face CSS
- Configure IBM Plex Sans (400/500/600/700) and IBM Plex Mono (400)
  with CSS variable overrides for Furo theme
- Add actions/cache step in docs workflow for font cache persistence
- Gitignore generated font assets in docs/_static/
why: Furo sets --font-stack on body, so :root declarations lose
in specificity. Using body selector matches Furo's own pattern.
what:
- Change CSS variable container from :root to body in _generate_css
why: The browser doesn't discover font URLs until it parses fonts.css,
which itself must wait for the HTML to load. Preload hints in <head>
tell the browser to start downloading fonts immediately.
what:
- Add sphinx_font_preload config option to sphinx_fonts extension
- Emit <link rel="preload"> tags in page.html template's extrahead block
- Preload 3 critical weights: Sans 400/700, Mono 400
- Rename layout.html → page.html (Furo extends !page.html)
why: IBM Plex Sans benefits from OpenType features that browsers
disable by default; monospace blocks need opposite treatment.
what:
- Add font-kerning, font-variant-ligatures, letter-spacing to body
- Add optimizeSpeed and disable ligatures for pre/code/kbd/samp
why: Every <img> on the docs site lacked dimension hints, causing
Cumulative Layout Shift (CLS) on page load.
what:
- Add content-visibility: auto on all img for off-screen decode skip
- Add height: auto !important for lazy-loaded images with aspect-ratio
- Add CSS height: 20px for shields.io / badge / codecov badges
- Add sidebar/brand.html with width/height/decoding on logo
why: Every page navigation re-downloads and re-parses CSS/JS/fonts
and re-renders the entire layout. Only article content, TOC, and
active sidebar link actually change between pages.
what:
- Create docs/_static/js/spa-nav.js (~170 lines, vanilla JS, no deps)
- Intercept internal link clicks and swap three DOM regions
- Preserve sidebar scroll, theme state, all CSS/JS/fonts
- Register in conf.py setup() with loading_method="defer"
…flow

why: When web fonts load, text reflowed because system fallbacks
have different metrics. Capsize-derived overrides make fallback
fonts match IBM Plex dimensions exactly.
what:
- Add sphinx_font_fallbacks config with size-adjust/ascent/descent
  overrides for Arial (sans) and Courier New (mono)
- Update font stacks to include fallback font families
why: Badges from shields.io/codecov render at 0x0 until loaded,
causing visible layout shift in the header area.
what:
- Add min-width: 60px, border-radius, and background placeholder
  to badge selectors for stable pre-load dimensions
why: All links render visibly then JS replaces the hostname-matching
link with a bold span, causing a visible reflow.
what:
- Remove misleading class="current" from all project links
- Hide #sidebar-projects until JS resolves active state (.ready)
- Use textContent instead of innerHTML for safer DOM manipulation
…sfade

why: SPA navigation instantly replaces DOM content, causing a jarring
visual jump between pages instead of a smooth transition.
what:
- Wrap swap+reinit in document.startViewTransition() when available
- Add 150ms crossfade animation via ::view-transition pseudo-elements
- Progressive enhancement: unsupported browsers get instant swap
why: View transitions are not image-related — grouping them with
image CLS rules is misleading. Moving to end of file keeps
related sections together and matches the logical reading order.
what:
- Move view transitions CSS block after badge placeholder rules
why: font-display swap causes visible text reflow (FOUT). Matching the
tony.nl/cv approach: block rendering until preloaded fonts arrive, and
inline the @font-face CSS to eliminate the extra fonts.css request.
what:
- Change font-display from swap to block
- Move @font-face CSS from external fonts.css to inline <style> in <head>
- Use pathto() in template for correct relative font URLs
- Remove _generate_css() function (CSS now generated in Jinja template)
tony added 6 commits March 14, 2026 15:17
why: ruff D101/D103 rules flag missing docstrings on SetupDict
and setup(), causing CI lint failures in repos that lint docs/_ext/.
what:
- Add docstring to SetupDict TypedDict class
- Add docstring to setup() function
why: codecov drops because docs/_ext/sphinx_fonts.py is measured
for coverage but has zero tests across all repos.
what:
- Add test_sphinx_fonts.py with 21 tests covering all functions
- Add test infrastructure (conftest, __init__) for docs/_ext tests
- Test pure functions, I/O with monkeypatch, Sphinx events with SimpleNamespace
- Cover all branches: cached/success/URLError/OSError, html/non-html, empty/with fonts
why: CI ruff check fails on EM101 (string literal in exception),
TRY003 (long exception message), and D403 (uncapitalized docstring).
what:
- Extract exception messages to variables for EM101/TRY003
- Capitalize docstrings starting with "setup" for D403
why: CI ruff format check fails on multi-line function call formatting.
what:
- Apply ruff format to test_sphinx_fonts.py
why: CI mypy fails with unused-ignore (sphinx_fonts is untyped) and
duplicate module (docs/_ext/conftest.py conflicts with root conftest.py).
what:
- Remove all type: ignore[arg-type] comments from test_sphinx_fonts.py
- Remove docs/_ext/conftest.py (not needed, sphinx_fonts has no doctests)
why: sphinx_fonts is a local docs/_ext extension, not an installed
package — mypy cannot resolve it at analysis time.
what:
- Add sphinx_fonts to [[tool.mypy.overrides]] ignore_missing_imports
@tony tony marked this pull request as ready for review March 14, 2026 20:51
@tony tony merged commit d841b39 into master Mar 14, 2026
13 checks passed
@tony tony deleted the docs-fonts branch March 14, 2026 20:51
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