Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a02aa2
docs(style[toc,body]): refine right-panel TOC and body typography
tony Mar 14, 2026
61a6c70
docs(style[toc,content]): flexible TOC width with inner-panel padding
tony Mar 14, 2026
c094d6b
docs(style[toc]): increase TOC font size from 81.25% to 87.5%
tony Mar 14, 2026
588e0b1
docs(style[headings]): refine heading hierarchy — scale, spacing, eye…
tony Mar 14, 2026
8c60a1f
docs(fonts): self-host IBM Plex via Fontsource CDN
tony Mar 14, 2026
b9b1645
docs(fonts[css]): fix variable specificity — use body instead of :root
tony Mar 14, 2026
1ab377f
docs(fonts[preload]): add <link rel="preload"> for critical font weights
tony Mar 14, 2026
5e2187d
docs(fonts[css]): add kerning, ligatures, and code rendering overrides
tony Mar 14, 2026
4a6ff51
docs(images[cls]): prevent layout shift and add non-blocking loading
tony Mar 14, 2026
50e6fd7
docs(nav[spa]): add SPA-like navigation to avoid full page reloads
tony Mar 14, 2026
cae61f9
docs(fonts[fallback]): add fallback font metrics to eliminate FOUT re…
tony Mar 14, 2026
12b7cec
docs(images[badges]): add placeholder sizing for external badge images
tony Mar 14, 2026
975fc11
docs(sidebar[projects]): prevent active link flash with visibility gate
tony Mar 14, 2026
b47afb5
docs(nav[spa]): wrap DOM swap in View Transitions API for smooth cros…
tony Mar 14, 2026
cc24577
docs(css[structure]): move view transitions section after image rules
tony Mar 14, 2026
40b07af
docs(fonts[loading]): switch to font-display block with inline CSS
tony Mar 14, 2026
8d76aa3
docs(fonts[lint]): add docstrings to sphinx_fonts extension
tony Mar 14, 2026
5d9043e
test(docs[sphinx_fonts]): add tests for sphinx_fonts extension
tony Mar 14, 2026
7c13c8a
test(docs[sphinx_fonts]): fix ruff lint errors in test_sphinx_fonts
tony Mar 14, 2026
8d283fa
test(docs[sphinx_fonts]): apply ruff format to test_sphinx_fonts
tony Mar 14, 2026
4222d6b
test(docs[sphinx_fonts]): remove type: ignore comments for mypy compat
tony Mar 14, 2026
5cab546
pyproject(mypy): add sphinx_fonts to ignore_missing_imports
tony Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ jobs:
python -V
uv run python -V

- name: Cache sphinx fonts
if: env.PUBLISH == 'true'
uses: actions/cache@v5
with:
path: ~/.cache/sphinx-fonts
key: sphinx-fonts-${{ hashFiles('docs/conf.py') }}
restore-keys: |
sphinx-fonts-

- name: Build documentation
if: env.PUBLISH == 'true'
run: |
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,7 @@ pip-wheel-metadata/
monkeytype.sqlite3

**/.claude/settings.local.json

# Generated by sphinx_fonts extension (downloaded at build time)
docs/_static/fonts/
docs/_static/css/fonts.css
153 changes: 153 additions & 0 deletions docs/_ext/sphinx_fonts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Sphinx extension for self-hosted fonts via Fontsource CDN.

Downloads font files at build time, caches them locally, and passes
structured font data to the template context for inline @font-face CSS.
"""

from __future__ import annotations

import logging
import pathlib
import shutil
import typing as t
import urllib.error
import urllib.request

if t.TYPE_CHECKING:
from sphinx.application import Sphinx

logger = logging.getLogger(__name__)

CDN_TEMPLATE = (
"https://cdn.jsdelivr.net/npm/{package}@{version}"
"/files/{font_id}-{subset}-{weight}-{style}.woff2"
)


class SetupDict(t.TypedDict):
"""Return type for Sphinx extension setup()."""

version: str
parallel_read_safe: bool
parallel_write_safe: bool


def _cache_dir() -> pathlib.Path:
return pathlib.Path.home() / ".cache" / "sphinx-fonts"


def _cdn_url(
package: str,
version: str,
font_id: str,
subset: str,
weight: int,
style: str,
) -> str:
return CDN_TEMPLATE.format(
package=package,
version=version,
font_id=font_id,
subset=subset,
weight=weight,
style=style,
)


def _download_font(url: str, dest: pathlib.Path) -> bool:
if dest.exists():
logger.debug("font cached: %s", dest.name)
return True
dest.parent.mkdir(parents=True, exist_ok=True)
try:
urllib.request.urlretrieve(url, dest)
logger.info("downloaded font: %s", dest.name)
except (urllib.error.URLError, OSError):
if dest.exists():
dest.unlink()
logger.warning("failed to download font: %s", url)
return False
return True


def _on_builder_inited(app: Sphinx) -> None:
if app.builder.format != "html":
return

fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts
variables: dict[str, str] = app.config.sphinx_font_css_variables
if not fonts:
return

cache = _cache_dir()
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"],
"style": style,
"weight": str(weight),
"filename": filename,
}
)

preload_hrefs: list[str] = []
preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload
for family_name, weight, style in preload_specs:
for font in fonts:
if font["family"] == family_name:
font_id = font["package"].split("/")[-1]
subset = font.get("subset", "latin")
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
preload_hrefs.append(filename)
break

fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks

app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined]
app._font_faces = font_faces # type: ignore[attr-defined]
app._font_fallbacks = fallbacks # type: ignore[attr-defined]
app._font_css_variables = variables # type: ignore[attr-defined]


def _on_html_page_context(
app: Sphinx,
pagename: str,
templatename: str,
context: dict[str, t.Any],
doctree: t.Any,
) -> None:
context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", [])
context["font_faces"] = getattr(app, "_font_faces", [])
context["font_fallbacks"] = getattr(app, "_font_fallbacks", [])
context["font_css_variables"] = getattr(app, "_font_css_variables", {})


def setup(app: Sphinx) -> SetupDict:
"""Register config values, events, and return extension metadata."""
app.add_config_value("sphinx_fonts", [], "html")
app.add_config_value("sphinx_font_fallbacks", [], "html")
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,
}
214 changes: 214 additions & 0 deletions docs/_static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,220 @@
margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5);
}

#sidebar-projects:not(.ready) {
visibility: hidden;
}

.sidebar-tree .active {
font-weight: bold;
}


/* ── Global heading refinements ─────────────────────────────
* Biome-inspired scale: medium weight (500) throughout — size
* and spacing carry hierarchy, not boldness. H4-H6 add eyebrow
* treatment (uppercase, muted). `article` prefix overrides
* Furo's bare h1-h6 selectors.
* ────────────────────────────────────────────────────────── */
article h1 {
font-size: 1.8em;
font-weight: 500;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}

article h2 {
font-size: 1.6em;
font-weight: 500;
margin-top: 2.5rem;
margin-bottom: 0.5rem;
}

article h3 {
font-size: 1.15em;
font-weight: 500;
margin-top: 1.5rem;
margin-bottom: 0.375rem;
}

article h4 {
font-size: 0.85em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-foreground-secondary);
margin-top: 1rem;
margin-bottom: 0.25rem;
}

article h5 {
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-foreground-secondary);
}

article h6 {
font-size: 0.75em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-foreground-secondary);
}

/* ── Changelog heading extras ───────────────────────────────
* Vertical spacing separates consecutive version entries.
* Category headings (h3) are muted. Item headings (h4) are
* subtle. Targets #history section from CHANGES markdown.
* ────────────────────────────────────────────────────────── */

/* Spacing between consecutive version entries */
#history > section + section {
margin-top: 2.5rem;
}

/* Category headings — muted secondary color */
#history h3 {
color: var(--color-foreground-secondary);
margin-top: 1.25rem;
}

/* Item headings — subtle, same size as body */
#history h4 {
font-size: 1em;
margin-top: 1rem;
text-transform: none;
letter-spacing: normal;
color: inherit;
}

/* ── Right-panel TOC refinements ────────────────────────────
* Adjust Furo's table-of-contents proportions for better
* readability. Inspired by Starlight defaults (Biome docs).
* Uses Furo CSS variable overrides where possible.
* ────────────────────────────────────────────────────────── */

/* TOC font sizes: override Furo defaults (75% → 87.5%) */
:root {
--toc-font-size: var(--font-size--small); /* 87.5% = 14px */
--toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */
}

/* More generous line-height for wrapped TOC entries */
.toc-tree {
line-height: 1.4;
}

/* ── Flexible right-panel TOC (inner-panel padding) ─────────
* Furo hardcodes .toc-drawer to width: 15em (SASS, compiled).
* min-width: 18em overrides it; long TOC entries wrap inside
* the box instead of blowing past the viewport.
*
* Padding lives on .toc-sticky (the inner panel), not on
* .toc-drawer (the outer aside). This matches Biome/Starlight
* where the aside defines dimensions and an inner wrapper
* (.right-sidebar-panel) controls content insets. The
* scrollbar sits naturally between content and viewport edge.
*
* Content area gets flex: 1 to absorb extra space on wide
* screens. At ≤82em Furo collapses the TOC to position: fixed;
* override right offset so the drawer fully hides off-screen.
* ────────────────────────────────────────────────────────── */
.toc-drawer {
min-width: 18em;
flex-shrink: 0;
padding-right: 0;
}

.toc-sticky {
padding-right: 1.5em;
}

.content {
width: auto;
max-width: 46em;
flex: 1 1 46em;
padding: 0 2em;
}

@media (max-width: 82em) {
.toc-drawer {
right: -18em;
}
}

/* ── Body typography refinements ────────────────────────────
* Improve paragraph readability with wider line-height and
* sharper text rendering. Furo already sets font-smoothing.
*
* IBM Plex tracks slightly wide at default spacing; -0.01em
* tightens it to feel more natural (matches tony.sh/tony.nl).
* Kerning + ligatures polish AV/To pairs and fi/fl combos.
* ────────────────────────────────────────────────────────── */
body {
text-rendering: optimizeLegibility;
font-kerning: normal;
font-variant-ligatures: common-ligatures;
letter-spacing: -0.01em;
}

/* ── Code block text rendering ────────────────────────────
* Monospace needs fixed-width columns: disable kerning,
* ligatures, and letter-spacing that body sets for prose.
* optimizeSpeed skips heuristics that can shift the grid.
* ────────────────────────────────────────────────────────── */
pre,
code,
kbd,
samp {
text-rendering: optimizeSpeed;
font-kerning: none;
font-variant-ligatures: none;
letter-spacing: normal;
}

article {
line-height: 1.6;
}

/* ── Image layout shift prevention ────────────────────────
* Reserve space for images before they load. Furo already
* sets max-width: 100%; height: auto on img. We add
* content-visibility and badge-specific height to prevent CLS.
* ────────────────────────────────────────────────────────── */


img {
content-visibility: auto;
}

/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx;
* height: Ypx;") rather than HTML attributes. When Furo's
* max-width: 100% constrains width below the declared value,
* the fixed height causes distortion. height: auto + aspect-ratio
* lets the browser compute the correct height from the intrinsic
* ratio once loaded; before load, aspect-ratio reserves space
* at the intended proportion — preventing both CLS and distortion. */
article img[loading="lazy"] {
height: auto !important;
}

img[src*="shields.io"],
img[src*="badge.svg"],
img[src*="codecov.io"] {
height: 20px;
width: auto;
min-width: 60px;
border-radius: 3px;
background: var(--color-background-secondary);
}

/* ── View Transitions (SPA navigation) ────────────────────
* Crossfade between pages during SPA navigation.
* Browsers without View Transitions API get instant swap.
* ────────────────────────────────────────────────────────── */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
Loading
Loading