Skip to content

perf: self-host the Source Sans 3 webfont - #140

Merged
mmcky merged 4 commits into
mainfrom
perf/self-host-source-sans-3
Aug 24, 2026
Merged

perf: self-host the Source Sans 3 webfont#140
mmcky merged 4 commits into
mainfrom
perf/self-host-source-sans-3

Conversation

@mmcky

@mmcky mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Drops the last third-party origin from the critical path: Source Sans 3 is now served from the site's own origin.

Note

Stacked on #139. Based on fix/relative-css-asset-urls, not main. Without that fix the emitted font URLs are absolute /myst_assets_folder/… paths that resolve under myst start but 404 in myst build --html — so the font would fail silently in exactly the mode the lectures deploy in. Review or merge #139 first; GitHub will retarget this to main automatically.

Why this one matters more than the CDN stylesheets already removed

styles/app.css opened with a CSS @import to fonts.googleapis.com. Two things make it a worse problem than the jsdelivr stylesheets #124/#125 dealt with:

  • It is the body font on every page, not the maths on some of them. Where Google Fonts is blocked — mainland China, a significant share of the QuantEcon readership — the whole lecture renders in the system sans.
  • An @import is the worst shape a critical-path request can have. It is discovered only once app.css has downloaded and parsed, so the browser cannot preload it, and the chain ran app.css → Google's CSS → fonts.gstatic.com woff2 across two extra origins before any text could paint in the intended face.

What changed

The font ships from @fontsource-variable/source-sans-3, imported from app/links.ts — the module #125 introduced for KaTeX — so esbuild rewrites the url()s and emits the 14 .woff2 files alongside every other bundled asset.

It deliberately is not @imported from styles/app.css: Tailwind does not rebase url() inside an imported stylesheet, so the paths would resolve against the Tailwind output file and 404.

Two stylesheets are imported, upright and italic. Lecture prose uses both, and without the italic faces the browser synthesises an oblique from the upright, which measures wider and shifts the layout.

The links are declared on the root route rather than the two page routes like KatexCSS, because root's links() are the only ones that also apply when the root ErrorBoundary renders — the body font should be right on a 404 too.

The package declares the family as Source Sans 3 Variable, so tailwind.config.js and the inlined CRITICAL_CSS in app/root.tsx name it that way as well, keeping plain Source Sans 3 next in the stack for a locally installed copy and sans-serif as the tail.

First paint is unaffected

Worth being explicit, since this touches the critical CSS the #123 FOUC guard protects. The @font-face rules live in a <link>, so they are not available on the first painted frame — and they were not before either, since the Google @import sat inside app.css. The sans-serif tail is what paints in both cases; the webfont swaps in when the stylesheet lands. Nothing about the FOUC guard's premise changes.

The guard itself is tightened: it asserted /Source Sans 3/, and "Source Sans 3 Variable" contains that string, so the old regex would have kept passing even if CRITICAL_CSS and tailwind.config.js drifted apart. It now matches the head of the stack.

Verification

Static build gate first, since that is the mode that decides the mechanism — a real myst build --html of the fixture against make build-theme:

check result
absolute myst_assets_folder URLs left in any stylesheet 0
asset references that resolve on disk 74 of 74
Source Sans 3 woff2 files in the static output 14
googleapis references in the built CSS 0
declared family in the compiled bundle Source Sans 3 Variable,Source Sans 3,sans-serif

Then: npm run compile clean · npm run test:fouc 2 passed · full visual suite 19 passed, 0 failed with every baseline untouched — which is the evidence that rendered text is unchanged, i.e. fontsource's build is metric-identical to what Google was serving.

Closes #131

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 20, 2026 05:55
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-24 23:19 UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR self-hosts the Source Sans 3 font (variable + italic) so the body font no longer depends on Google Fonts and avoids an @import-driven critical-path request chain. It integrates the font via the existing Remix/esbuild “import CSS → emit assets” flow used for KaTeX, and updates the font-family stack in Tailwind + inline critical CSS accordingly.

Changes:

  • Add @fontsource-variable/source-sans-3 and serve its upright/italic CSS from app/links.ts, applied from the root route.
  • Remove the Google Fonts @import from styles/app.css and update font-family stack usages (tailwind.config.js, CRITICAL_CSS, FOUC guard test).
  • Document the change in CHANGELOG.md.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/visual/fouc.spec.ts Tightens the FOUC guard to check the updated font stack.
tailwind.config.js Updates theme.extend.fontFamily.sans to include "Source Sans 3 Variable".
styles/app.css Removes Google Fonts @import and documents why imports must come from Remix, not CSS.
package.json Adds @fontsource-variable/source-sans-3 dependency.
package-lock.json Locks the new font package and updates lockfile metadata.
CHANGELOG.md Records the switch from Google Fonts to self-hosted Source Sans 3.
app/root.tsx Uses the new font stack in CRITICAL_CSS and adds root-level <link>s for the font CSS.
app/links.ts Adds SourceSans3CSS link descriptors for upright + italic stylesheets.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/visual/fouc.spec.ts Outdated
@mmcky

mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Note on CI coverage while this is stacked: ci.yml triggers on pull_request: branches: [main], so a PR based on another branch gets only the preview job — no typecheck, visual or FOUC run here yet. They will run automatically once #139 merges and GitHub retargets this to main.

Locally, against a make build-theme of this branch: npm run compile clean, test:fouc 2 passed, full visual suite 19 passed / 0 failed with baselines untouched, and a real myst build --html resolves 74 of 74 asset references with 0 absolute myst_assets_folder URLs remaining.

mmcky added a commit that referenced this pull request Aug 20, 2026
The comment claimed the assertion checked the head of the declared
stack, but an unanchored regex matches anywhere in it — so the guard
would still pass with "Source Sans 3 Variable" demoted behind another
family, which is the drift it exists to catch.

Raised by Copilot on #140.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmcky
mmcky requested a balanced review from Copilot August 24, 2026 22:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 8 changed files in this pull request and generated no new comments.

mmcky and others added 2 commits August 25, 2026 09:10
…ve them

Remix rewrites every `url()` in a bundled stylesheet to
`${publicPath}_assets/<file>` — absolute, because `publicPath` is also
how it loads JS chunks and so cannot itself be relative. Under
`myst start` that resolves, since `template/server.js` mounts
`public/build` at exactly that path. A static `myst build --html` has no
such route, and mystmd's asset rewriter only touches `.html`, `.js` and
`.json`, never `.css` — so the path inside the stylesheet kept pointing
at a directory the output does not contain.

The effect: the KaTeX stylesheet self-hosted in #125 loads in a static
build, and all 60 of its font references 404. Maths then renders with
system fallback glyphs — the degradation that change set out to prevent,
now on every statically built site rather than only where jsdelivr is
blocked. Verified against a real `myst build --html` of the visual
fixture: the `<link>` is rewritten to `/build/_assets/…` and loads, no
`myst_assets_folder/` directory exists in the output, and the fonts sit
in `build/_assets/` unreferenced.

The stylesheets and the files they reference are emitted into the same
`_assets/` directory in both layouts, so a reference relative to the
stylesheet resolves in either — and under a `baseurl` too, where the
absolute path was equally wrong and would have broken the per-PR preview
deployments.

Adds a post-build step that rewrites those references and then checks
every rewritten target exists beside its stylesheet, so a wrong
assumption fails the build rather than shipping silent 404s. On the same
fixture the static build now resolves 60 of 60.

Only the KaTeX stylesheet is affected today; `app.css` and
`thebe-core.css` emit no `url()` references at all.

Closes #138

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two points from Copilot's review of #139.

The script wrote each rewritten stylesheet as it went and only failed
afterwards, so a missing target left the build output half-corrected —
confusing to debug, and worse to inherit if a later step ever ran despite
the non-zero exit. Rewrites are now buffered and applied only once every
reference has been checked; the failure path says "nothing written".

The assets directory was also hardcoded as public/build/_assets while
remix.config.prod.js already declares assetsBuildDirectory. Both that and
publicPath now come from the config, so the script cannot drift out of
step with where the build actually puts things.

Verified by seeding a reference to a file that does not exist: the script
reports it, exits 1, and the stylesheet is left exactly as it was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmcky
mmcky force-pushed the fix/relative-css-asset-urls branch from 36845c7 to 49a2e69 Compare August 24, 2026 23:10
mmcky and others added 2 commits August 25, 2026 09:11
`styles/app.css` opened with a CSS `@import` to fonts.googleapis.com.
Google Fonts is blocked in mainland China, a significant share of the
QuantEcon readership, and unlike the CDN stylesheets dropped in 2.3.0
this one is the body font on every page rather than the maths on some of
them — when it fails, the lectures render in the system sans throughout.

It was also the worst possible shape for a critical-path request. An
`@import` is discovered only once app.css has downloaded and parsed, so
the browser cannot preload it, and the chain ran app.css -> Google's CSS
-> gstatic woff2 across two extra origins before any text could paint in
the intended face.

The font now ships from `@fontsource-variable/source-sans-3`, imported
from `app/links.ts` — the module #125 introduced for KaTeX — so esbuild
rewrites the `url()`s and emits the 14 woff2 files alongside every other
bundled asset. It cannot be `@import`ed from `styles/app.css` instead:
Tailwind does not rebase `url()` inside an imported stylesheet, so the
paths would resolve against the Tailwind output file and 404.

The links are declared on the root route rather than the two page routes
like `KatexCSS`, because root's are the only ones that also apply when
the root ErrorBoundary renders — the body font has to be right on a 404
too.

The package declares the family as "Source Sans 3 Variable", so
tailwind.config.js and the inlined CRITICAL_CSS name it that way as
well, keeping plain "Source Sans 3" next in the stack for a locally
installed copy. The FOUC guard is tightened to match the head of the
stack rather than a substring, since "Source Sans 3 Variable" contains
"Source Sans 3" and the looser regex would have kept passing if those
two declarations ever drifted apart.

Verified end to end on a real `myst build --html`: no absolute asset
URLs remain in any stylesheet, all 74 references resolve, and the 14
woff2 files are present in the output. Rendered text is unchanged — the
full visual suite passes against untouched baselines.

Closes #131

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment claimed the assertion checked the head of the declared
stack, but an unanchored regex matches anywhere in it — so the guard
would still pass with "Source Sans 3 Variable" demoted behind another
family, which is the drift it exists to catch.

Raised by Copilot on #140.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmcky
mmcky force-pushed the perf/self-host-source-sans-3 branch from 1be9cbd to 17ea92a Compare August 24, 2026 23:11
Base automatically changed from fix/relative-css-asset-urls to main August 24, 2026 23:19
@mmcky

mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@DrDrij merging this.

@mmcky
mmcky merged commit 8904019 into main Aug 24, 2026
1 check passed
@mmcky
mmcky deleted the perf/self-host-source-sans-3 branch August 24, 2026 23:19
mmcky added a commit that referenced this pull request Aug 24, 2026
…hosting (#148)

* test: refresh linux mobile-chrome baselines after Source Sans 3 self-hosting

The @fontsource-variable build of Source Sans 3 has marginally different
glyph metrics from the Google-served static font it replaced in #140, so
a couple of lines wrap one word differently at the mobile viewport width
and all five linux mobile-chrome snapshots drift past the diff budget.
Desktop snapshots are unaffected. #140 never got a visual-regression run
before merge: as a PR stacked on fix/relative-css-asset-urls it only
triggered the preview job, and the base retarget after #139 merged does
not fire a synchronize event.

Baselines to be refreshed via /update-snapshots on this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: refresh CI visual snapshots (linux baselines)

* ci: nudge — snapshot-bot pushes do not trigger CI

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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.

Self-host the Source Sans 3 webfont (drop the fonts.googleapis.com @import)

2 participants