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. */} +