Skip to content

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

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

docs: self-host fonts, eliminate layout shift, add SPA navigation#534
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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.24%. Comparing base (f268230) to head (5cab546).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #534   +/-   ##
=======================================
  Coverage   84.24%   84.24%           
=======================================
  Files          29       29           
  Lines        3795     3795           
  Branches      756      756           
=======================================
  Hits         3197     3197           
  Misses        378      378           
  Partials      220      220           

☔ 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 3 issues. Checked for bugs and CLAUDE.md compliance.


Issue 1 (score 90) — Bug: font_faces entries added to template even when download fails

app.add_config_value("sphinx_font_css_variables", {}, "html")
app.add_config_value("sphinx_font_preload", [], "html")
app.connect("builder-inited", _on_builder_inited)
app.connect("html-page-context", _on_html_page_context)
return {
"version": "1.0",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

font_faces.append(...) runs unconditionally — the return value of _download_font gates the shutil.copy2 but not the append. When a download fails, the @font-face CSS will still emit a src: URL pointing to a file that was never copied to _static/fonts/. With font-display: block, this blocks rendering waiting for a font that will 404.

The test test_on_builder_inited_download_failure asserts that a font_faces entry is created on failure (line 1197), which confirms this is the current behaviour, but it doesn't test that the built page functions without the file.

Fix: only append to font_faces when _download_font returns True, or at minimum change to font-display: swap for the failure path.


Issue 2 (score 85) — Temporary CI branch trigger left in workflow

The commit message for this line says "temporarily add docs-fonts branch to trigger". The branch is still listed in push.branches in the PR being merged. Once this lands on master, the entry is dead code that will never match and will confuse future contributors. It should be removed before merging, matching the pattern in the parent tmuxp PR which has a "Revert" commit.


Issue 3 (score 82) — Missing docstrings on _cache_dir and _cdn_url

static_dir = pathlib.Path(app.outdir) / "_static"
fonts_dir = static_dir / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
font_faces: list[dict[str, str]] = []
for font in fonts:
font_id = font["package"].split("/")[-1]
version = font["version"]
package = font["package"]
subset = font.get("subset", "latin")
for weight in font["weights"]:
for style in font["styles"]:
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
cached = cache / filename
url = _cdn_url(package, version, font_id, subset, weight, style)
if _download_font(url, cached):
shutil.copy2(cached, fonts_dir / filename)
font_faces.append(
{
"family": font["family"],

Both functions lack docstrings. CLAUDE.md requires NumPy-style docstrings for all functions and methods. _cache_dir in particular has no docstring despite _make_app (in the test file) and SetupDict both having them. _cdn_url and _download_font also lack docstrings; _download_font at least has clear variable names but a one-liner summary would satisfy the convention.

🤖 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
- 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 because sphinx_fonts is imported
as an untyped module via sys.path, making arg-type suppression unnecessary.
what:
- Remove all type: ignore[arg-type] comments from test_sphinx_fonts.py
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:50
@tony tony merged commit b8f81d3 into master Mar 14, 2026
7 checks passed
@tony tony deleted the docs-fonts branch March 14, 2026 21:08
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