Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 28 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -94,6 +94,32 @@ Suspense boundaries on every console route use `<ConsolePageSkeleton>` 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
(<https://rsms.me/inter/>, 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).
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 5 additions & 9 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -62,7 +58,7 @@ export default function ConsoleLayout({
<AuthProvider>
<EnvironmentProvider>
<div
className={`flex min-h-screen flex-col bg-dark font-sans md:h-screen md:min-h-0 md:flex-row md:overflow-hidden ${GeistSans.variable} ${GeistMono.variable}`}
className="flex min-h-screen flex-col bg-dark font-sans md:h-screen md:min-h-0 md:flex-row md:overflow-hidden"
style={consoleOverrides}
>
<ConsoleSidebar />
Expand Down
20 changes: 4 additions & 16 deletions app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,22 @@
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 = {
title: "Sign in — Livepeer Console",
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,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div
className={`min-h-screen bg-dark font-sans ${GeistSans.variable} ${GeistMono.variable}`}
style={geistOverride}
>
{children}
</div>
<div className="min-h-screen bg-dark font-sans">{children}</div>
</AuthProvider>
);
}
89 changes: 89 additions & 0 deletions app/fonts.css
Original file line number Diff line number Diff line change
@@ -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 `<em>` 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;
}
16 changes: 15 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 14 additions & 6 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";

Expand Down Expand Up @@ -28,12 +27,21 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${GeistSans.variable} ${GeistMono.variable}`}
>
<html lang="en" suppressHydrationWarning className={GeistMono.variable}>
<head>
{/* 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. */}
<link
rel="preload"
href="/fonts/InterVariable-latin.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body className="min-h-screen bg-background font-sans text-foreground antialiased">
Expand Down
Binary file added public/fonts/InterVariable-Italic-latin.woff2
Binary file not shown.
Binary file added public/fonts/InterVariable-latin.woff2
Binary file not shown.
Binary file added public/fonts/InterVariable.woff2
Binary file not shown.
Loading