From 1e197f4c6b9b5c6cd530b08be4ea4d6fbc02f319 Mon Sep 17 00:00:00 2001 From: adamsoffer Date: Mon, 24 Aug 2026 15:12:52 -0400 Subject: [PATCH] Switch font-sans from Geist to self-hosted Inter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geist Sans is replaced by Inter for `font-sans`; Geist Mono is unchanged and still backs `font-mono`. Inter comes from the typeface author's own distribution (https://rsms.me/inter/, OFL), vendored into public/fonts/ rather than linked from the CDN. Why Inter here: the console subtree runs a 13.5px body with 10.5-11px labels, so every glyph in the UI sits at or below the small end of Inter v4's optical size axis (opsz 14-32) and benefits from it automatically. Geist has one drawing at every size. Inter's more open apertures also hold up better against light-on-dark bloom at those sizes, and its 2,852-codepoint coverage (Greek, Cyrillic, Vietnamese) matters for user-supplied display and org names. Why self-hosted rather than the rsms.me CDN: the font now preloads over the connection that already delivered the HTML instead of waiting on a third-party stylesheet round-trip, which cannot be collapsed with `preload` because the CDN font URL is version-stamped. Browser HTTP caches have been partitioned per top-level site since ~2020, so a shared CDN buys no cross-site cache reuse. Self-hosting also removes a render-blocking third-party SPOF and stops leaking visitor IPs. Two roman faces are served: InterVariable-latin.woff2 107 KB latin + UI symbols, preloaded InterVariable.woff2 344 KB pristine, demand-loaded for non-Latin Every `unicode-range` is generated from the actual cmap of the woff2 it points at, never hand-written. This is load-bearing, not tidiness: a hand-written blanket range made the browser fetch all 344 KB for a single `✕` in the Skip button, discover the glyph was absent from Inter entirely, and only then fall through to a system font — 448 KB total, worse than the CDN baseline it replaced. Deriving the ranges designs that class of bug out and makes the two roman ranges disjoint, so declaration order carries no meaning. scripts/build-inter-fonts.py fetches, subsets, generates app/fonts.css, and verifies. Its `--check` mode fails on any glyph that would land on the 344 KB fallback, and reports glyphs Inter lacks entirely (`✕`, `▾`) separately as zero-cost. fontTools is deliberately not a package.json dependency — the script is run by hand when bumping Inter and its output is committed. The `--font-sans` / `--font-mono` overrides in the (app) and (auth) layouts are removed: they only restated what the root layout already sets, and as inline styles they would have pinned those subtrees to a single face. Verified in-browser: a normal page load fetches only the 107 KB subset and never requests the fallback; injecting Cyrillic/Greek/Vietnamese text loads it on demand and renders correctly. lint, typecheck, build, and prettier pass. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 30 +- README.md | 9 +- app/(app)/layout.tsx | 14 +- app/(auth)/layout.tsx | 20 +- app/fonts.css | 89 ++++++ app/globals.css | 16 +- app/layout.tsx | 20 +- public/fonts/InterVariable-Italic-latin.woff2 | Bin 0 -> 120804 bytes public/fonts/InterVariable-latin.woff2 | Bin 0 -> 109820 bytes public/fonts/InterVariable.woff2 | Bin 0 -> 352240 bytes public/fonts/OFL.txt | 92 ++++++ scripts/build-inter-fonts.py | 280 ++++++++++++++++++ 12 files changed, 534 insertions(+), 36 deletions(-) create mode 100644 app/fonts.css create mode 100644 public/fonts/InterVariable-Italic-latin.woff2 create mode 100644 public/fonts/InterVariable-latin.woff2 create mode 100644 public/fonts/InterVariable.woff2 create mode 100644 public/fonts/OFL.txt create mode 100755 scripts/build-inter-fonts.py diff --git a/CLAUDE.md b/CLAUDE.md index 3d8597a..0aaef87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -**Stack:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4, Framer Motion 11, Geist Sans/Mono, Lucide, Recharts. Package manager: **pnpm**. No test framework. +**Stack:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4, Framer Motion 11, Inter (rsms.me) + Geist Mono, Lucide, Recharts. Package manager: **pnpm**. No test framework. ## Commands @@ -14,7 +14,7 @@ ``` app/ -├── layout.tsx # Root layout — html/body, Geist fonts, theme bootstrap (FOUT-safe) +├── layout.tsx # Root layout — html/body, Inter + Geist Mono, theme bootstrap (FOUT-safe) ├── globals.css # Tailwind v4 @theme block + console token layer ├── (app)/ # Console chrome (sidebar, providers, keyboard shortcuts) │ ├── layout.tsx @@ -94,6 +94,32 @@ Suspense boundaries on every console route use `` as the fa - Lucide React. Default stroke width is 1.5. Override only when the glyph reads too thin at the size you're using (e.g. `Activity` at sizes ≥ 16px reads better at `strokeWidth={1.75}`). - Sizes: `h-3.5 w-3.5` (14px) for inline label icons, `h-4 w-4` (16px) for buttons / nav, `h-5 w-5` (20px) for cards, `h-10 w-10` (40px) for empty-state hero glyphs. +### Typefaces + +`font-sans` is **Inter**, self-hosted from the designer's own distribution +(, OFL). Files live in `public/fonts/`; the `@font-face` +rules live in `app/fonts.css`, which is **generated** — don't hand-edit it. + +Two roman faces are served: a ~107 KB latin+symbols subset (preloaded in +`app/layout.tsx`, the only one most sessions fetch) and the full 344 KB file, +demand-loaded only for non-Latin text such as a user display name. Each face's +`unicode-range` is derived from the actual cmap of the woff2 it points at. + +**If you add a non-ASCII glyph to any UI string, run the generator:** + +```bash +python3 scripts/build-inter-fonts.py --check +``` + +It reports any glyph that would fall onto the 344 KB fallback; add those to +`SYMBOLS` in the script and re-run it without `--check` to rebuild. This is not +cosmetic — a single uncovered glyph bills every visitor 344 KB. A stray `✕` did +exactly that. (Glyphs Inter lacks entirely, like `✕` and `▾`, are reported +separately and cost nothing; they render in a system fallback.) + +Don't reintroduce a `--font-sans` override in a subtree layout — it would +shadow the variable font. + ### Monospace Use `font-mono` for IDs, hashes, tokens, addresses, model `id` slugs (e.g. `daydream-video`), latency / cost / count numbers (`tabular-nums`). Use the default sans for human names (model display name, model provider, user display name). diff --git a/README.md b/README.md index d4f14a0..29a1b1e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This repo was extracted from [`livepeer/website`](https://github.com/livepeer/we - Next.js 15 (App Router), React 19, TypeScript - Tailwind CSS v4 (`@tailwindcss/postcss`) -- Geist Sans + Mono via `geist` +- Inter, self-hosted from [rsms.me/inter](https://rsms.me/inter/) + Geist Mono via `geist` - Framer Motion 11, Lucide icons, Recharts - Package manager: pnpm @@ -24,13 +24,18 @@ pnpm dev # http://localhost:3000 pnpm build # production build (verify before pushing) pnpm lint # ESLint, zero warnings pnpm typecheck # tsc --noEmit + +# Fonts — only when bumping Inter or adding a non-ASCII UI glyph. +# Verifies no glyph falls onto the 344 KB fallback; see CLAUDE.md. +python3 scripts/build-inter-fonts.py --check ``` ## Layout ``` app/ -├── layout.tsx # Root: html/body, Geist fonts, theme bootstrap +├── layout.tsx # Root: html/body, Inter preload, theme bootstrap +├── fonts.css # GENERATED — @font-face rules (build-inter-fonts.py) ├── globals.css # Token layer + console utilities ├── (app)/ # Console chrome (sidebar, providers) │ ├── layout.tsx diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index caa6d88..5e0e0b5 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,7 +1,5 @@ import type { Metadata } from "next"; import type { CSSProperties } from "react"; -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; import { AuthProvider } from "@/components/console/AuthContext"; import { EnvironmentProvider } from "@/components/console/EnvironmentContext"; import { ThemeProvider } from "@/components/console/ThemeContext"; @@ -27,18 +25,16 @@ export const metadata: Metadata = { "Browse AI apps, manage API keys, and monitor usage on the Livepeer network.", }; -// The console runs on Geist (Vercel's open-source font) instead of Favorit Pro — -// the console is a *tool*, the marketing site is the brand. We attach the Geist -// CSS variables to this subtree and override `--font-sans` / `--font-mono` so -// every Tailwind `font-sans` / `font-mono` consumer below this point picks Geist. +// Typography comes from the root layout: Inter for `font-sans` (rsms.me +// distribution) and Geist Mono for `font-mono`. The console is a *tool*, the +// marketing site is the brand — so this subtree deliberately does not inherit +// Favorit Pro. It only sets density here; the families are global. // // Density: per the Livepeer Console design (Claude Design handoff, Apr 2026), // the console subtree uses a 13.5px body with a slightly tighter letter-spacing // to land in the same density bracket as Linear. Sidebar width and chrome head // height are exposed as custom properties so components can reference them. const consoleOverrides = { - "--font-sans": "var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif", - "--font-mono": "var(--font-geist-mono), ui-monospace, monospace", "--side-w": "232px", "--head-h": "44px", fontSize: "13.5px", @@ -62,7 +58,7 @@ export default function ConsoleLayout({
diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index b1bb364..7697773 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,7 +1,4 @@ import type { Metadata } from "next"; -import type { CSSProperties } from "react"; -import { GeistSans } from "geist/font/sans"; -import { GeistMono } from "geist/font/mono"; import { AuthProvider } from "@/components/console/AuthContext"; export const metadata: Metadata = { @@ -9,13 +6,9 @@ export const metadata: Metadata = { description: "Sign in or create an account to access the Livepeer Console.", }; -// Auth pages share the console's Geist typography rather than the marketing -// site's Favorit Pro — the "you've crossed into the tool" cliff starts here. -const geistOverride = { - "--font-sans": "var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif", - "--font-mono": "var(--font-geist-mono), ui-monospace, monospace", -} as CSSProperties; - +// Auth pages share the console's typography (Inter / Geist Mono, set in the +// root layout) rather than the marketing site's Favorit Pro — the "you've +// crossed into the tool" cliff starts here. export default function ConsoleAuthLayout({ children, }: { @@ -23,12 +16,7 @@ export default function ConsoleAuthLayout({ }) { return ( -
- {children} -
+
{children}
); } diff --git a/app/fonts.css b/app/fonts.css new file mode 100644 index 0000000..e4f0e3c --- /dev/null +++ b/app/fonts.css @@ -0,0 +1,89 @@ +/* GENERATED by scripts/build-inter-fonts.py — do not edit by hand. + * + * Inter v4.1, self-hosted from https://rsms.me/inter/ (OFL, see + * public/fonts/OFL.txt). Every `unicode-range` below is derived from the actual + * cmap of the file it points at, so no face can ever advertise a glyph it does + * not contain — see the module docstring in the generator for why that would + * cost every visitor a needless 344 KB. + * + * The two roman ranges are disjoint by construction, so declaration order here + * carries no meaning. + */ + +/* Latin + the UI symbols the console draws. The fast path: preloaded in + * app/layout.tsx and the only sans face most sessions ever fetch. */ +@font-face { + font-family: InterVariable; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("/fonts/InterVariable-latin.woff2") format("woff2"); + unicode-range: + U+0000, U+0020-007E, U+00A0-00AC, U+00AE-00FF, U+0131, U+0152-0153, + U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0394, U+2000-200B, + U+2010-2027, U+202F-2055, U+2057, U+205F, U+2074, U+20AC, U+2122, + U+2190-2193, U+21B5, U+21E7, U+2212, U+221E, U+2260, U+2264-2265, U+2303, + U+2318, U+2325, U+232B, U+238B, U+25B2-25B3, U+25B6-25B7, U+25BA-25BD, + U+25C0-25C1, U+25C4, U+2713, U+2717, U+FEFF; +} + +/* The full unmodified distribution file. Demand-loaded only when a page + * actually renders Greek, Cyrillic, Vietnamese or Latin-Extended — in practice + * a non-Latin user display name or org name. */ +@font-face { + font-family: InterVariable; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("/fonts/InterVariable.woff2") format("woff2"); + unicode-range: + U+0100-0130, U+0132-0148, U+014A-0151, U+0154-01C3, U+01C5-0254, + U+0256-027B, U+027E-0284, U+0286-0290, U+0292-02A4, U+02A6-02BA, + U+02BD-02C5, U+02C7-02D9, U+02DB, U+02DD-0303, U+0306-0307, U+0309-030A, + U+030C, U+030F, U+0313, U+0315, U+031B, U+0323, U+0326-0328, U+032C, + U+0337-0338, U+0342-0343, U+0346-036F, U+0374-0376, U+037A-037F, + U+0384-038A, U+038C, U+038E-0393, U+0395-03A1, U+03A3-03D7, U+03DC-03DD, + U+03F0-03F6, U+03F9-03FA, U+03FC-0479, U+0480-049D, U+04A0-04FF, U+052F, + U+0E3F, U+1D00, U+1D0D, U+1D1B, U+1D43, U+1D47-1D49, U+1D4D, U+1D4F-1D50, + U+1D52, U+1D56-1D58, U+1D5B, U+1D62-1D65, U+1D9C, U+1DA0, U+1DBB, + U+1DBF-1DF5, U+1DFC-1E9B, U+1E9D-1F15, U+1F18-1F1D, U+1F20-1F45, + U+1F48-1F4D, U+1F50-1F57, U+1F59, U+1F5B, U+1F5D, U+1F5F-1F7D, U+1F80-1FB4, + U+1FB6-1FC4, U+1FC6-1FD3, U+1FD6-1FDB, U+1FDD-1FEF, U+1FF2-1FF4, + U+1FF6-1FFE, U+2070-2071, U+2075-208E, U+2090-209C, U+20A0-20AB, + U+20AD-20AF, U+20B1-20B5, U+20B8-20BA, U+20BC-20C0, U+20DB-20DE, U+20E8, + U+20F0, U+2100-2101, U+2103, U+2105-2106, U+2109, U+2113, U+2116-2117, + U+211E-2121, U+2126, U+212A-212B, U+212E, U+2132, U+213B, U+214D, + U+2150-217F, U+2183-2186, U+2189, U+2194-2199, U+21A9-21AA, U+21B0-21B1, + U+21B3-21B4, U+21BA-21BB, U+21D0, U+21D2, U+21D4, U+21DE-21DF, U+21E4-21E5, + U+21EA, U+2202, U+2205-2206, U+220F, U+2211, U+221A, U+222B, U+2236, U+2248, + U+2295-2298, U+2304-2305, U+2326-2327, U+2380, U+2387, U+23CE-23CF, U+2423, + U+2460-2468, U+24B6-24CF, U+24EA, U+25A0-25A2, U+25AA, U+25C5-25C7, + U+25CA-25CB, U+25CF, U+25E6, U+25EF, U+2600, U+2605-2606, U+263C, U+2661, + U+2665, U+266A-266B, U+26A0, U+2756, U+2764, U+2780-2788, U+27EF, + U+27F5-27FA, U+2913, U+2A38, U+2B06, U+2B12-2B13, U+2B1C, U+2B24, U+2C7C, + U+2C7F, U+2DFF, U+2E18, U+A69F, U+A7FF, U+A92E, U+E000, U+E002-E05E, + U+E06A-E0BD, U+E0C8-E0CC, U+E0DC-E0E6, U+E0F3-E0F5, U+E106, U+E109-E10A, + U+E10C-E10F, U+E111-E113, U+E117-E118, U+E121-E122, U+E124, U+E12A-E15E, + U+E163, U+E1C3, U+E1D2-E1DF, U+E1E1-E2DC, U+EE01-EE07, U+EE09-EE0A, + U+EE0C-EE12, U+EE14, U+EE17, U+EE1D-EE45, U+EE47-EE84, U+EE87-EED6, + U+EED8-EEE1, U+F6C3, U+1F12F-1F149, U+1F16A-1F16B, U+1F850, U+1F852; +} + +/* True italic, latin only. The console renders no italics today, so this is + * never fetched; it exists so a future `` gets Inter's real italic + * letterforms rather than a synthesised oblique. Non-Latin italic falls back to + * a synthesised oblique of the roman, which is the graceful outcome. */ +@font-face { + font-family: InterVariable; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url("/fonts/InterVariable-Italic-latin.woff2") format("woff2"); + unicode-range: + U+0000, U+0020-007E, U+00A0-00AC, U+00AE-00FF, U+0131, U+0152-0153, + U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0394, U+2000-200B, + U+2010-2027, U+202F-2055, U+2057, U+205F, U+2074, U+20AC, U+2122, + U+2190-2193, U+21B5, U+21E7, U+2212, U+221E, U+2260, U+2264-2265, U+2303, + U+2318, U+2325, U+232B, U+238B, U+25B2-25B3, U+25B6-25B7, U+25BA-25BD, + U+25C0-25C1, U+25C4, U+2713, U+2717, U+FEFF; +} diff --git a/app/globals.css b/app/globals.css index 0abb619..3591884 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./fonts.css"; /* Custom variant: targets descendants of html[data-theme="light"]. Use as `light:bg-foo` to override values when the page theme is light. */ @@ -37,10 +38,17 @@ --color-muted-foreground: rgba(255, 255, 255, 0.7); --color-subtle-foreground: rgba(255, 255, 255, 0.5); - --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-sans: InterVariable, Inter, ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, monospace; } +/* Inter's @font-face rules live in app/fonts.css, which is GENERATED by + scripts/build-inter-fonts.py — each face's unicode-range is derived from the + actual cmap of the woff2 it points at. Run that script to bump Inter or after + introducing a new symbol glyph; it reports anything that would fall onto the + 344 KB fallback. `Inter` stays in the stack below purely as a local-install + fallback for the vanishingly rare no-variable-font case. */ + /* ── Dark theme (default) ──────────────────────────────────────── */ :root { --color-background: #121212; @@ -73,6 +81,12 @@ html { body { background-color: var(--color-background); color: var(--color-foreground); + /* Ligatures + contextual alternates — Inter needs these turned on + explicitly in Chrome. */ + font-feature-settings: + "liga" 1, + "calt" 1; + font-optical-sizing: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; transition: diff --git a/app/layout.tsx b/app/layout.tsx index 76679d7..112d23a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import "./globals.css"; @@ -28,12 +27,21 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + + {/* Inter, self-hosted from the author's distribution — see the + generated @font-face rules in app/fonts.css (scripts/build-inter-fonts.py). + Preloaded because it's on the critical render path for every page; + `crossorigin` is required even same-origin, as fonts are always + fetched in CORS mode. Only the latin subset is preloaded — the + full-coverage fallback is demand-loaded. */} +