docs: self-host fonts, eliminate layout shift, add SPA navigation#1022
Merged
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…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
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)
This was referenced Mar 14, 2026
This was referenced Mar 14, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Overhaul the docs frontend for faster, smoother page loads:
<link rel="preload">to eliminate Flash of Unstyled Text (FOUT)size-adjust,ascent-override,descent-overrideon system fallbacks so text doesn't reflow when web fonts loadaspect-ratioCSS,loading="lazy", and badge placeholder sizing to eliminate Cumulative Layout Shift (CLS)optimizeSpeedon code blocks; Biome-inspired heading scale; wider TOC panelFonts
sphinx_fontsextension downloads fonts at build time, caches in~/.cache/sphinx-fonts/, generates@font-faceCSS, and overrides Furo's CSS variables onbody(not:root— Furo'sbodydeclarations 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 onadd_css_file()URLs, which caused double downloads when the@font-faceURLs didn't match.Fallback metrics: System font stacks (
-apple-system,Segoe UI, etc.) getsize-adjust,ascent-override,descent-override, andline-gap-overridevalues 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, andletter-spacing: -0.01emto tighten IBM Plex's slightly wide default tracking. Code blocks (pre,code,kbd,samp) override these withoptimizeSpeed,font-kerning: none, andletter-spacing: normalto 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
#historyget custom overrides.TOC panel widened from 15em to 18em min-width with inner padding on
.toc-stickyfor better readability on wrapped entries.Layout shift
Images get
width/height/loading="lazy"attributes,aspect-ratioCSS reserves space before load, and the sidebar logo getsdecoding="async". Shields.io badges get fixed 20px height, 60px min-width, and abackgroundplaceholder to prevent 0→20px shift. The brand template sets explicitwidth/height/decodingon 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 withDOMParser(no script execution), and swaps three regions:What gets swapped:
.article-container,.sidebar-tree,.toc-drawerWhat is preserved: sidebar scroll position, theme toggle state, all
<link>/<script>tags, font preloads,.sidebar-brand,.sidebar-search-container,#sidebar-projects, mobile headerPost-swap reinit:
cloneNodefrom captured template (ClipboardJS event delegation handles clicks automatically).scroll-currentto TOC based on scroll position.content-icon-containerbutton (replicates Furo'scycleThemeOncelogic)Skip interception for: external links,
search.html/genindex.html/py-modindex.html, modifier-key clicks (Ctrl/Meta = new tab),#sidebar-projectslinks, same-page hash links, download attributesFailure modes: network error or missing selectors → falls back to
window.location.hrefView Transitions
DOM swaps are wrapped in
document.startViewTransition()when available. The CSS setsanimation-duration: 150mson::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-projectsstarts withvisibility: hiddenvia CSS and gets a.readyclass 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-fontsbranch. Font files are cached viaactions/cachekeyed on the extension source to avoid re-downloading on every build.Continues from a58026b. See also #1021.
Test plan
uv run ruff check docs/passesuv run mypy docs/_ext/sphinx_fonts.pypassessphinx-buildcompletes with no new errorsperformance.getEntriesByType('navigation').lengthstays 1)<title>update correctly