Skip to content

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

Merged
tony merged 14 commits into
masterfrom
docs-fonts
Mar 14, 2026
Merged

docs: self-host fonts, eliminate layout shift, add SPA navigation#1022
tony merged 14 commits into
masterfrom
docs-fonts

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented Mar 13, 2026

Summary

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 <link rel="preload"> to eliminate Flash of Unstyled Text (FOUT)
  • Font fallback metrics — Capsize-derived size-adjust, ascent-override, descent-override on system fallbacks so text doesn't reflow when web fonts load
  • Layout shift prevention — dimension hints, aspect-ratio CSS, loading="lazy", and badge placeholder sizing 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

Fonts

sphinx_fonts extension downloads fonts at build time, caches in ~/.cache/sphinx-fonts/, generates @font-face CSS, and overrides Furo's CSS variables on body (not :root — Furo's body declarations win otherwise). Three critical weights are preloaded via <link rel="preload"> in the template to avoid FOUT. The template approach avoids Sphinx's ?v= query string on add_css_file() URLs, which caused double downloads when the @font-face URLs didn't match.

Fallback metrics: System font stacks (-apple-system, Segoe UI, etc.) get size-adjust, ascent-override, descent-override, and line-gap-override values derived from Capsize metrics. This ensures the fallback font occupies the same space as IBM Plex, eliminating visible text reflow when the web font loads.

Typography

Body text gets font-kerning: normal, font-variant-ligatures: common-ligatures, and letter-spacing: -0.01em to tighten IBM Plex's slightly wide default tracking. Code blocks (pre, code, kbd, samp) override these with optimizeSpeed, font-kerning: none, and letter-spacing: normal to preserve fixed-width grid alignment.

Heading scale follows a Biome-inspired approach: medium weight (500) throughout, with size and spacing carrying hierarchy instead of boldness. H4–H6 get eyebrow treatment (uppercase, muted color). Changelog headings in #history get custom overrides.

TOC panel widened from 15em to 18em min-width with inner padding on .toc-sticky for better readability on wrapped entries.

Layout shift

Images get width/height/loading="lazy" attributes, aspect-ratio CSS reserves space before load, and the sidebar logo gets decoding="async". Shields.io badges get fixed 20px height, 60px min-width, and a background placeholder to prevent 0→20px shift. The brand template sets explicit width/height/decoding on the logo.

SPA navigation

Every page navigation previously triggered a full reload — re-downloading 8 CSS files, 7 JS files, 3 fonts, and re-rendering the entire layout. Only the article, TOC, and active sidebar link actually change.

The script intercepts clicks on internal <a> tags via event delegation, fetches the target page, parses it with DOMParser (no script execution), and swaps three regions:

What gets swapped: .article-container, .sidebar-tree, .toc-drawer

What is preserved: sidebar scroll position, theme toggle state, all <link>/<script> tags, font preloads, .sidebar-brand, .sidebar-search-container, #sidebar-projects, mobile header

Post-swap reinit:

  • Copy buttons injected on new code blocks via cloneNode from captured template (ClipboardJS event delegation handles clicks automatically)
  • Minimal scrollspy (~25 lines) adds .scroll-current to TOC based on scroll position
  • Theme toggle re-attached to new .content-icon-container button (replicates Furo's cycleThemeOnce logic)

Skip interception for: external links, search.html/genindex.html/py-modindex.html, modifier-key clicks (Ctrl/Meta = new tab), #sidebar-projects links, same-page hash links, download attributes

Failure modes: network error or missing selectors → falls back to window.location.href

View Transitions

DOM swaps are wrapped in document.startViewTransition() when available. The CSS sets animation-duration: 150ms on ::view-transition-old(root) and ::view-transition-new(root) for a quick crossfade. Browsers without the API get an instant swap (no polyfill needed).

Sidebar visibility gate

#sidebar-projects starts with visibility: hidden via CSS and gets a .ready class added by JS after the active link is set. This prevents a flash where all project links briefly appear unstyled before the active state is applied.

CI

Docs build workflow temporarily includes docs-fonts branch. Font files are cached via actions/cache keyed on the extension source to avoid re-downloading on every build.

Continues from a58026b. See also #1021.

Test plan

  • uv run ruff check docs/ passes
  • uv run mypy docs/_ext/sphinx_fonts.py passes
  • sphinx-build completes with no new errors
  • SPA: click sidebar link — content swaps without full reload (performance.getEntriesByType('navigation').length stays 1)
  • SPA: browser URL and <title> update correctly
  • SPA: back button works (popstate swaps content back)
  • SPA: copy buttons present on new page's code blocks after navigation
  • SPA: theme toggle works after SPA navigation
  • SPA: TOC scrollspy highlights correct heading after scroll
  • SPA: external links open normally (not intercepted)
  • SPA: Ctrl+click opens in new tab
  • SPA: search page navigates normally (no interception)
  • Fonts: IBM Plex renders on first paint (no FOUT with preload)
  • Fonts: fallback metrics — no visible text reflow when web font loads
  • View Transitions: crossfade animation between pages (Chrome/Edge)
  • Sidebar: no flash of active link state on page load
  • Images: no layout shift on page load
  • Badges: gray placeholder visible before shield images load
  • CSS: sections are contiguous and logically ordered

tony added 7 commits March 13, 2026 17:45
why: Test font deployment before merging to master.
what:
- Add docs-fonts to push trigger branches (revert after verification)
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, which overrides :root via direct
declaration. Our fonts.css loaded but never rendered.
what:
- Change CSS variable selector from :root to body in _generate_css()
- Same specificity + later source order ensures our override wins
why: The browser doesn't discover font URLs until it parses fonts.css,
which itself must wait for the HTML to load. This creates a waterfall
(HTML → CSS → font download) that causes a visible Flash of Unstyled
Text (FOUT) — the page renders with system fallback fonts, then swaps
to IBM Plex once the fonts arrive.

Preload hints in <head> tell the browser to start downloading fonts
immediately, in parallel with CSS parsing, so fonts arrive before
first paint and the swap is invisible.

what:
- Add sphinx_font_preload config option to sphinx_fonts extension
  accepting (family, weight, style) tuples for selective preloading
- Compute preload filenames in _on_builder_inited() and pass them
  to templates via html-page-context event handler
- Emit <link rel="preload" as="font" type="font/woff2" crossorigin>
  tags in page.html template's extrahead block
- Preload only 3 critical above-the-fold weights: Sans 400 (body),
  Sans 700 (headings), Mono 400 (code) — other variants load on demand
- Rename layout.html → page.html and extend !page.html instead of
  !layout.html — Furo theme blocks layout.html inheritance (its
  layout.html is deliberately an error page)

note: The original approach used Sphinx's add_css_file() for preload
tags, but Sphinx appends ?v=<hash> query strings to those URLs.
The @font-face declarations in fonts.css reference fonts without
query strings, so the browser treated them as different resources —
downloading each font twice and defeating the preload entirely.
The template-based approach produces URLs without query strings,
matching @font-face exactly: one download, zero FOUT.
why: IBM Plex's OpenType features (kern, liga) weren't being leveraged,
and code blocks inherited prose text-rendering that can break monospace
grid alignment.
what:
- Add font-kerning, font-variant-ligatures, letter-spacing to body
- Add optimizeSpeed + kerning/ligature/spacing resets for pre/code/kbd/samp
why: Every <img> on the docs site lacked dimension hints, causing
Cumulative Layout Shift (CLS) on page load — content jumped as images
rendered. External badge images (shields.io, Codecov) were especially
slow, and the 447KB demo GIF blocked rendering on hard refresh.
what:
- Add :width:, :height:, :loading: lazy to image directives in
  index.md (888×589), cli/shell.md (878×109), developing.md (1030×605)
- Add CSS aspect-ratio per image to reserve proportional space before
  load; override docutils inline height with height: auto !important
  so Furo's max-width: 100% scales without distortion
- Add content-visibility: auto on all img for off-screen decode skip
- Add CSS height: 20px for shields.io / badge.svg / codecov.io badges
  to prevent 0→20px shift while external images load
- Add sidebar/brand.html template override with width="200" height="200"
  decoding="async" on logo <img> (above-fold, no lazy loading)
why: Every page navigation re-downloads and re-parses 8 CSS files,
7 JS files, 3 fonts, re-renders the sidebar, SVG icons, and the
entire layout. Only the article content, right-panel 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 via event delegation on document
- Fetch target page, parse with DOMParser, swap three DOM regions:
  .article-container, .sidebar-tree, .toc-drawer
- Preserve sidebar scroll position, theme state, all CSS/JS/fonts
- Replicate Furo's cycleThemeOnce for swapped .content-icon-container
- Inject copy buttons on new code blocks via cloneNode from template
  (ClipboardJS event delegation picks up new .copybtn elements)
- Minimal scrollspy (~25 lines) replaces Gumshoe for swapped TOC
- AbortController cancels in-flight fetches on rapid navigation
- history.pushState/popstate for back/forward browser navigation
- Debounced prefetch on hover (65ms) for near-instant transitions
- Progressive enhancement: no-op if fetch/DOMParser/pushState absent
- Skip interception for search.html, genindex, external links,
  modifier-key clicks, download attrs, and #sidebar-projects links
- Fallback to window.location.href on network error or missing DOM
- Register script in conf.py setup() with loading_method="defer"
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 80.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.01%. Comparing base (9f13395) to head (6564409).
⚠️ Report is 15 commits behind head on master.

Files with missing lines Patch % Lines
docs/conf.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1022      +/-   ##
==========================================
- Coverage   81.01%   81.01%   -0.01%     
==========================================
  Files          28       28              
  Lines        2623     2628       +5     
  Branches      492      492              
==========================================
+ Hits         2125     2129       +4     
- Misses        367      368       +1     
  Partials      131      131              

☔ 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 added 4 commits March 13, 2026 18:21
…flow

why: font-display: swap causes visible text reflow when IBM Plex loads
because the system fallback (Arial) has different character dimensions.
what:
- Add sphinx_font_fallbacks config with size-adjust/ascent/descent overrides
- Generate fallback @font-face declarations in fonts.css (Capsize formula)
- Include fallback families in --font-stack CSS variables
why: badges flash in from zero width because width: auto computes to 0
before the image loads, causing cumulative layout shift.
what:
- Add min-width: 60px to reserve approximate badge width
- Add border-radius: 3px matching shields.io badge shape
- Add background placeholder that disappears behind loaded badge
why: all links render with class="current" then JS replaces the
hostname-matching link with a bold span, causing a visible reflow
when IBM Plex fonts load with different metrics than the fallback.
what:
- Remove misleading class="current" from all project links
- Hide #sidebar-projects until JS resolves active state (.ready class)
- 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
tony added 2 commits March 14, 2026 04:45
why: The view transitions block was inserted between the "Image layout
shift prevention" section header and its rules, orphaning the comment.
what:
- Move view transitions comment + rules to end of file
- Keep image section header contiguous with its img/badge rules
why: Image is below the fold; lazy loading defers fetch until needed.
what:
- Add :loading: lazy to the figure directive in about_tmux.md
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 at any page depth
- Remove _generate_css() function (CSS now generated in Jinja template)
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