Skip to content

Commit b8f81d3

Browse files
authored
docs: Refactor with typesetting and SPA-like transitions (#534)
why: Improve documentation UX across the project ecosystem with faster loads, zero layout shift, and smooth navigation β€” delivering a polished reading experience on par with modern documentation sites. what: Self-hosted fonts with build-time caching - Download IBM Plex Sans/Mono from Fontsource CDN at docs build time - Cache to ~/.cache/sphinx-fonts/ with CI caching via GitHub Actions - Generate inline font-face declarations with font-display: block - Preload critical font weights to eliminate Flash of Unstyled Text Layout shift prevention (CLS β†’ 0) - Font fallback metrics (size-adjust, ascent/descent/line-gap overrides) prevent text reflow when web fonts load - Badge/image placeholders with fixed dimensions prevent content jumping - Sidebar logo gets explicit width/height with decoding="async" SPA-like page navigation - Vanilla JS (~236 lines, zero dependencies) intercepts internal links - Swaps only .article-container, .sidebar-tree, and .toc-drawer regions - Preserves sidebar scroll position, theme state, and copy buttons - Hover prefetch (65ms delay) for near-instant perceived navigation - History API integration for native back/forward behavior - View Transitions API crossfade (150ms) with graceful degradation Typography and readability - Heading hierarchy: weight 500 (not bold), sized h1β†’h6 with spacing - Body: line-height 1.6, optimizeLegibility, kerning, common ligatures - Code: optimizeSpeed, no ligatures, no kerning β€” clean monospace grid - TOC: 87.5% font size, 1.4 line-height, min-width 18em for long entries - Content area: max-width 46em for optimal reading line length Testing and type safety - 21 test functions (545 lines) covering downloads, caching, error handling, Sphinx builder integration, and template context injection - mypy strict mode compatible with targeted overrides for local extension Ported from tmuxp#1021 and tmuxp#1022.
1 parent f268230 commit b8f81d3

11 files changed

Lines changed: 1276 additions & 11 deletions

File tree

β€Ž.github/workflows/docs.ymlβ€Ž

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ jobs:
6363
python -V
6464
uv run python -V
6565
66+
- name: Cache sphinx fonts
67+
if: env.PUBLISH == 'true'
68+
uses: actions/cache@v5
69+
with:
70+
path: ~/.cache/sphinx-fonts
71+
key: sphinx-fonts-${{ hashFiles('docs/conf.py') }}
72+
restore-keys: |
73+
sphinx-fonts-
74+
6675
- name: Build documentation
6776
if: env.PUBLISH == 'true'
6877
run: |

β€Ž.gitignoreβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,7 @@ pip-wheel-metadata/
8585
monkeytype.sqlite3
8686

8787
**/.claude/settings.local.json
88+
89+
# Generated by sphinx_fonts extension (downloaded at build time)
90+
docs/_static/fonts/
91+
docs/_static/css/fonts.css

β€Ždocs/_ext/sphinx_fonts.pyβ€Ž

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Sphinx extension for self-hosted fonts via Fontsource CDN.
2+
3+
Downloads font files at build time, caches them locally, and passes
4+
structured font data to the template context for inline @font-face CSS.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import pathlib
11+
import shutil
12+
import typing as t
13+
import urllib.error
14+
import urllib.request
15+
16+
if t.TYPE_CHECKING:
17+
from sphinx.application import Sphinx
18+
19+
logger = logging.getLogger(__name__)
20+
21+
CDN_TEMPLATE = (
22+
"https://cdn.jsdelivr.net/npm/{package}@{version}"
23+
"/files/{font_id}-{subset}-{weight}-{style}.woff2"
24+
)
25+
26+
27+
class SetupDict(t.TypedDict):
28+
"""Return type for Sphinx extension setup()."""
29+
30+
version: str
31+
parallel_read_safe: bool
32+
parallel_write_safe: bool
33+
34+
35+
def _cache_dir() -> pathlib.Path:
36+
return pathlib.Path.home() / ".cache" / "sphinx-fonts"
37+
38+
39+
def _cdn_url(
40+
package: str,
41+
version: str,
42+
font_id: str,
43+
subset: str,
44+
weight: int,
45+
style: str,
46+
) -> str:
47+
return CDN_TEMPLATE.format(
48+
package=package,
49+
version=version,
50+
font_id=font_id,
51+
subset=subset,
52+
weight=weight,
53+
style=style,
54+
)
55+
56+
57+
def _download_font(url: str, dest: pathlib.Path) -> bool:
58+
if dest.exists():
59+
logger.debug("font cached: %s", dest.name)
60+
return True
61+
dest.parent.mkdir(parents=True, exist_ok=True)
62+
try:
63+
urllib.request.urlretrieve(url, dest)
64+
logger.info("downloaded font: %s", dest.name)
65+
except (urllib.error.URLError, OSError):
66+
if dest.exists():
67+
dest.unlink()
68+
logger.warning("failed to download font: %s", url)
69+
return False
70+
return True
71+
72+
73+
def _on_builder_inited(app: Sphinx) -> None:
74+
if app.builder.format != "html":
75+
return
76+
77+
fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts
78+
variables: dict[str, str] = app.config.sphinx_font_css_variables
79+
if not fonts:
80+
return
81+
82+
cache = _cache_dir()
83+
static_dir = pathlib.Path(app.outdir) / "_static"
84+
fonts_dir = static_dir / "fonts"
85+
fonts_dir.mkdir(parents=True, exist_ok=True)
86+
87+
font_faces: list[dict[str, str]] = []
88+
for font in fonts:
89+
font_id = font["package"].split("/")[-1]
90+
version = font["version"]
91+
package = font["package"]
92+
subset = font.get("subset", "latin")
93+
for weight in font["weights"]:
94+
for style in font["styles"]:
95+
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
96+
cached = cache / filename
97+
url = _cdn_url(package, version, font_id, subset, weight, style)
98+
if _download_font(url, cached):
99+
shutil.copy2(cached, fonts_dir / filename)
100+
font_faces.append(
101+
{
102+
"family": font["family"],
103+
"style": style,
104+
"weight": str(weight),
105+
"filename": filename,
106+
}
107+
)
108+
109+
preload_hrefs: list[str] = []
110+
preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload
111+
for family_name, weight, style in preload_specs:
112+
for font in fonts:
113+
if font["family"] == family_name:
114+
font_id = font["package"].split("/")[-1]
115+
subset = font.get("subset", "latin")
116+
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
117+
preload_hrefs.append(filename)
118+
break
119+
120+
fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks
121+
122+
app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined]
123+
app._font_faces = font_faces # type: ignore[attr-defined]
124+
app._font_fallbacks = fallbacks # type: ignore[attr-defined]
125+
app._font_css_variables = variables # type: ignore[attr-defined]
126+
127+
128+
def _on_html_page_context(
129+
app: Sphinx,
130+
pagename: str,
131+
templatename: str,
132+
context: dict[str, t.Any],
133+
doctree: t.Any,
134+
) -> None:
135+
context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", [])
136+
context["font_faces"] = getattr(app, "_font_faces", [])
137+
context["font_fallbacks"] = getattr(app, "_font_fallbacks", [])
138+
context["font_css_variables"] = getattr(app, "_font_css_variables", {})
139+
140+
141+
def setup(app: Sphinx) -> SetupDict:
142+
"""Register config values, events, and return extension metadata."""
143+
app.add_config_value("sphinx_fonts", [], "html")
144+
app.add_config_value("sphinx_font_fallbacks", [], "html")
145+
app.add_config_value("sphinx_font_css_variables", {}, "html")
146+
app.add_config_value("sphinx_font_preload", [], "html")
147+
app.connect("builder-inited", _on_builder_inited)
148+
app.connect("html-page-context", _on_html_page_context)
149+
return {
150+
"version": "1.0",
151+
"parallel_read_safe": True,
152+
"parallel_write_safe": True,
153+
}

β€Ždocs/_static/css/custom.cssβ€Ž

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,220 @@
1515
margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5);
1616
}
1717

18+
#sidebar-projects:not(.ready) {
19+
visibility: hidden;
20+
}
21+
1822
.sidebar-tree .active {
1923
font-weight: bold;
2024
}
25+
26+
27+
/* ── Global heading refinements ─────────────────────────────
28+
* Biome-inspired scale: medium weight (500) throughout β€” size
29+
* and spacing carry hierarchy, not boldness. H4-H6 add eyebrow
30+
* treatment (uppercase, muted). `article` prefix overrides
31+
* Furo's bare h1-h6 selectors.
32+
* ────────────────────────────────────────────────────────── */
33+
article h1 {
34+
font-size: 1.8em;
35+
font-weight: 500;
36+
margin-top: 1.5rem;
37+
margin-bottom: 0.75rem;
38+
}
39+
40+
article h2 {
41+
font-size: 1.6em;
42+
font-weight: 500;
43+
margin-top: 2.5rem;
44+
margin-bottom: 0.5rem;
45+
}
46+
47+
article h3 {
48+
font-size: 1.15em;
49+
font-weight: 500;
50+
margin-top: 1.5rem;
51+
margin-bottom: 0.375rem;
52+
}
53+
54+
article h4 {
55+
font-size: 0.85em;
56+
font-weight: 500;
57+
text-transform: uppercase;
58+
letter-spacing: 0.05em;
59+
color: var(--color-foreground-secondary);
60+
margin-top: 1rem;
61+
margin-bottom: 0.25rem;
62+
}
63+
64+
article h5 {
65+
font-size: 0.8em;
66+
font-weight: 500;
67+
text-transform: uppercase;
68+
letter-spacing: 0.05em;
69+
color: var(--color-foreground-secondary);
70+
}
71+
72+
article h6 {
73+
font-size: 0.75em;
74+
font-weight: 500;
75+
text-transform: uppercase;
76+
letter-spacing: 0.05em;
77+
color: var(--color-foreground-secondary);
78+
}
79+
80+
/* ── Changelog heading extras ───────────────────────────────
81+
* Vertical spacing separates consecutive version entries.
82+
* Category headings (h3) are muted. Item headings (h4) are
83+
* subtle. Targets #history section from CHANGES markdown.
84+
* ────────────────────────────────────────────────────────── */
85+
86+
/* Spacing between consecutive version entries */
87+
#history > section + section {
88+
margin-top: 2.5rem;
89+
}
90+
91+
/* Category headings β€” muted secondary color */
92+
#history h3 {
93+
color: var(--color-foreground-secondary);
94+
margin-top: 1.25rem;
95+
}
96+
97+
/* Item headings β€” subtle, same size as body */
98+
#history h4 {
99+
font-size: 1em;
100+
margin-top: 1rem;
101+
text-transform: none;
102+
letter-spacing: normal;
103+
color: inherit;
104+
}
105+
106+
/* ── Right-panel TOC refinements ────────────────────────────
107+
* Adjust Furo's table-of-contents proportions for better
108+
* readability. Inspired by Starlight defaults (Biome docs).
109+
* Uses Furo CSS variable overrides where possible.
110+
* ────────────────────────────────────────────────────────── */
111+
112+
/* TOC font sizes: override Furo defaults (75% β†’ 87.5%) */
113+
:root {
114+
--toc-font-size: var(--font-size--small); /* 87.5% = 14px */
115+
--toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */
116+
}
117+
118+
/* More generous line-height for wrapped TOC entries */
119+
.toc-tree {
120+
line-height: 1.4;
121+
}
122+
123+
/* ── Flexible right-panel TOC (inner-panel padding) ─────────
124+
* Furo hardcodes .toc-drawer to width: 15em (SASS, compiled).
125+
* min-width: 18em overrides it; long TOC entries wrap inside
126+
* the box instead of blowing past the viewport.
127+
*
128+
* Padding lives on .toc-sticky (the inner panel), not on
129+
* .toc-drawer (the outer aside). This matches Biome/Starlight
130+
* where the aside defines dimensions and an inner wrapper
131+
* (.right-sidebar-panel) controls content insets. The
132+
* scrollbar sits naturally between content and viewport edge.
133+
*
134+
* Content area gets flex: 1 to absorb extra space on wide
135+
* screens. At ≀82em Furo collapses the TOC to position: fixed;
136+
* override right offset so the drawer fully hides off-screen.
137+
* ────────────────────────────────────────────────────────── */
138+
.toc-drawer {
139+
min-width: 18em;
140+
flex-shrink: 0;
141+
padding-right: 0;
142+
}
143+
144+
.toc-sticky {
145+
padding-right: 1.5em;
146+
}
147+
148+
.content {
149+
width: auto;
150+
max-width: 46em;
151+
flex: 1 1 46em;
152+
padding: 0 2em;
153+
}
154+
155+
@media (max-width: 82em) {
156+
.toc-drawer {
157+
right: -18em;
158+
}
159+
}
160+
161+
/* ── Body typography refinements ────────────────────────────
162+
* Improve paragraph readability with wider line-height and
163+
* sharper text rendering. Furo already sets font-smoothing.
164+
*
165+
* IBM Plex tracks slightly wide at default spacing; -0.01em
166+
* tightens it to feel more natural (matches tony.sh/tony.nl).
167+
* Kerning + ligatures polish AV/To pairs and fi/fl combos.
168+
* ────────────────────────────────────────────────────────── */
169+
body {
170+
text-rendering: optimizeLegibility;
171+
font-kerning: normal;
172+
font-variant-ligatures: common-ligatures;
173+
letter-spacing: -0.01em;
174+
}
175+
176+
/* ── Code block text rendering ────────────────────────────
177+
* Monospace needs fixed-width columns: disable kerning,
178+
* ligatures, and letter-spacing that body sets for prose.
179+
* optimizeSpeed skips heuristics that can shift the grid.
180+
* ────────────────────────────────────────────────────────── */
181+
pre,
182+
code,
183+
kbd,
184+
samp {
185+
text-rendering: optimizeSpeed;
186+
font-kerning: none;
187+
font-variant-ligatures: none;
188+
letter-spacing: normal;
189+
}
190+
191+
article {
192+
line-height: 1.6;
193+
}
194+
195+
/* ── Image layout shift prevention ────────────────────────
196+
* Reserve space for images before they load. Furo already
197+
* sets max-width: 100%; height: auto on img. We add
198+
* content-visibility and badge-specific height to prevent CLS.
199+
* ────────────────────────────────────────────────────────── */
200+
201+
202+
img {
203+
content-visibility: auto;
204+
}
205+
206+
/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx;
207+
* height: Ypx;") rather than HTML attributes. When Furo's
208+
* max-width: 100% constrains width below the declared value,
209+
* the fixed height causes distortion. height: auto + aspect-ratio
210+
* lets the browser compute the correct height from the intrinsic
211+
* ratio once loaded; before load, aspect-ratio reserves space
212+
* at the intended proportion β€” preventing both CLS and distortion. */
213+
article img[loading="lazy"] {
214+
height: auto !important;
215+
}
216+
217+
img[src*="shields.io"],
218+
img[src*="badge.svg"],
219+
img[src*="codecov.io"] {
220+
height: 20px;
221+
width: auto;
222+
min-width: 60px;
223+
border-radius: 3px;
224+
background: var(--color-background-secondary);
225+
}
226+
227+
/* ── View Transitions (SPA navigation) ────────────────────
228+
* Crossfade between pages during SPA navigation.
229+
* Browsers without View Transitions API get instant swap.
230+
* ────────────────────────────────────────────────────────── */
231+
::view-transition-old(root),
232+
::view-transition-new(root) {
233+
animation-duration: 150ms;
234+
}

0 commit comments

Comments
Β (0)