diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 2803cdf3421c..6f49f03f0eb6 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -273,3 +273,74 @@ - apps/desktop/src/app/DesktopClerk.test.ts verify: - apps/web/src/__fork_guards__/forkClerkLaunchResilience.test.ts + +- id: geist-typography + intent: > + The app's typefaces are Geist (UI) and Geist Mono (code), bundled as + Fontsource variable faces and imported from the fork's own theme layer — + web and Electron renderer only; mobile and marketing still ship DM Sans. + The stacks live in theme.custom.css as --fork-font-sans / --fork-font-mono + and index.css's @theme block reads them through + var(--fork-font-sans, ); that indirection is load-bearing, + not ceremony. The block is @theme inline, so Tailwind bakes font values + literally into .font-sans / .font-mono and their variants, and a compiled + utility cannot see a variable overridden in a later stylesheet — the same + constraint that forces the sidebar-v2 palette registrations to live in + index.css. Routing through --fork-font-* keeps the values scoped under the + fork marker while the fallbacks leave an unmarked build on DM Sans. + The terminal's font logic lives in custom/terminalFont.ts rather than in + the drawer: xterm takes its font from a constructor option instead of the + cascade, so --font-mono has to be resolved by hand and the cell grid + re-measured once the webfont lands — xterm sizes columns at open(), and + unlike the always-resident SF Mono a webfont can land after that, leaving + the grid measured against the fallback. That is fork feature logic with no + business in a 1.5k-line upstream mount effect. The drawer keeps two + one-line call sites, and the font-load probe is derived from the resolved + stack rather than hardcoded, so it cannot go stale on a face swap and an + unmarked build names a local system face and fetches nothing. The re-fit + also propagates to the PTY, because nothing in the drawer subscribes to + onResize — every upstream fit site calls resizeTerminal by hand, and a + re-fit that corrects only xterm's local grid leaves the PTY wrapping to the + stale, fallback-measured width. + Two further decisions are non-obvious. Geist Mono is listed AHEAD of SF Mono: + upstream puts SF Mono first, which means on macOS its bundled mono webfont + never renders at all, so preserving upstream's order would make shipping + Geist Mono pointless. And body / pre, code are re-declared under the fork + marker because upstream hardcodes the literal stacks on those selectors + (index.css:964, index.css:1022) instead of reading its own @theme tokens, + so overriding --font-sans / --font-mono alone does not reach them. + DM Sans / JetBrains Mono deps and their main.tsx imports are deliberately + left in place, but not because removing them would gain nothing, and the + two are not the same case. Nothing references DM Sans in a marked build, so + it costs nothing on the wire — but main.tsx still imports its Fontsource + CSS, so Vite emits the woff2 files into dist and into the Electron package + (roughly 60KB DM Sans, 96KB JetBrains Mono). For DM Sans the tradeoff is + therefore installer size, not request count, and it is the one worth + revisiting if package size matters; it is left as-is only to keep main.tsx + conflict-free. JetBrains Mono is NOT a size question: the fork's own mono + stack still names it, deliberately, as upstream's only bundled mono face — + the fallback for a Linux user with no SF Mono and no Consolas when the + Geist Mono fetch fails, which is exactly when it gets fetched. Dropping + that dependency on size grounds would silently strip the face out from + under a stack that still names it, and no guard would catch it: the guards + assert the family is listed, not that a bundled face backs it. + tier: 4 + files: + - apps/web/src/theme.custom.css + - apps/web/src/custom/terminalFont.ts + shadows: [] + watch: + - apps/web/src/index.css + - apps/web/src/components/ThreadTerminalDrawer.tsx + # No fence, but load-bearing: readPreviewAnnotationTheme reads --font-sans / + # --font-mono off documentElement and ships the resolved families into the + # previewed page, so the fork's stacks travel through here. Inert today (the + # previewed page has no Geist loaded, so it falls through to the same system + # faces as before), but it is the dynamic seam Annotation.css is not. + - apps/web/src/browser/annotationTheme.ts + # detect-drift only greps fences in ts/css/yaml/sh, so it cannot see a + # dependency change. This is the first customization to add npm deps; the + # guard asserts both are present, and this entry makes the file drift-watched. + - apps/web/package.json + verify: + - apps/web/src/__fork_guards__/geistTypography.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5ee8efb582ea..c6253466adfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,8 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", diff --git a/apps/web/src/__fork_guards__/geistTypography.test.ts b/apps/web/src/__fork_guards__/geistTypography.test.ts new file mode 100644 index 000000000000..7b395a6c5306 --- /dev/null +++ b/apps/web/src/__fork_guards__/geistTypography.test.ts @@ -0,0 +1,273 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/README.md` §4b and + * `.fork/customizations.yaml#geist-typography`. + * + * A rebase can succeed and still silently drop a customization: upstream + * rewrites the surrounding code, git resolves "cleanly", and the fork hunk + * evaporates with a green checkmark. These tests turn that into a red one. + * Guards assert outcomes, not implementation details. + * + * The terminal half is exercised as behaviour against the fork-owned module + * rather than grepped for inside the drawer: string assertions on an upstream + * file pin the code's *placement* instead of its effect, and would block moving + * it. Only the two call sites are checked textually, because a rebase quietly + * dropping them is precisely the failure this file exists to catch. + */ + +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker"; +import { + FORK_TERMINAL_FONT_FALLBACK, + firstFontFamily, + refitTerminalWhenFontsReady, + terminalFontFamilyFrom, + type ForkTerminalFontTarget, +} from "../custom/terminalFont"; + +function readSibling(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const MARKER = `:root[${FORK_MARKER_ATTRIBUTE}="${FORK_MARKER_VALUE}"]`; + +/** The `MARKER { … }` declaration block alone, so lost scoping is detectable. */ +function markerBlock(css: string): string { + const start = css.indexOf(`${MARKER} {`); + expect(start).toBeGreaterThanOrEqual(0); + const end = css.indexOf("\n}", start); + expect(end).toBeGreaterThan(start); + return css.slice(start, end); +} + +interface TerminalProbe { + readonly target: ForkTerminalFontTarget; + /** Every value written to `options.fontFamily`, in order. */ + readonly writes: string[]; + readonly scrolls: () => number; +} + +function terminalProbe(fontFamily: string, atBottom = true): TerminalProbe { + const writes: string[] = []; + let current = fontFamily; + let scrolls = 0; + return { + writes, + scrolls: () => scrolls, + target: { + cols: 80, + rows: 24, + options: { + get fontFamily() { + return current; + }, + set fontFamily(value: string) { + current = value; + writes.push(value); + }, + }, + buffer: { active: { viewportY: atBottom ? 5 : 0, baseY: 5 } }, + scrollToBottom: () => { + scrolls += 1; + }, + }, + }; +} + +function fakeFonts(loadResult: Promise = Promise.resolve([])) { + const requested: string[] = []; + const fonts = { + load: (font: string) => { + requested.push(font); + return loadResult; + }, + ready: Promise.resolve(), + } as unknown as Pick; + return { requested, fonts }; +} + +describe("fork guard: geist-typography", () => { + it("bundles both Geist faces as dependencies", () => { + const manifest = JSON.parse(readSibling("../../package.json")) as { + dependencies?: Record; + }; + expect(manifest.dependencies?.["@fontsource-variable/geist"]).toBeDefined(); + expect(manifest.dependencies?.["@fontsource-variable/geist-mono"]).toBeDefined(); + }); + + it("loads both Geist faces from the fork's own theme layer", () => { + const theme = readSibling("../theme.custom.css"); + expect(theme).toContain('@import "@fontsource-variable/geist/index.css"'); + expect(theme).toContain('@import "@fontsource-variable/geist-mono/index.css"'); + }); + + it("declares the Geist stacks inside the marker-scoped block", () => { + // Bounded to the block: a declaration that lost its scoping and moved to a + // bare `:root { }` has to fail here, which is the point of the test. + const block = markerBlock(readSibling("../theme.custom.css")); + expect(block).toContain("--fork-font-sans:"); + expect(block).toContain('"Geist Variable"'); + expect(block).toContain("--fork-font-mono:"); + expect(block).toContain('"Geist Mono Variable"'); + }); + + it("keeps upstream's @theme tokens reading through the fork indirection", () => { + // `@theme inline` bakes font values literally into `.font-sans` / + // `.font-mono` and their variants, so a compiled utility can never see a + // variable overridden in a later stylesheet. Lose this indirection and + // every `font-mono`-classed element silently reverts to SF Mono while the + // rest of the app stays on Geist. + // Whitespace-tolerant: the formatter decides whether these wrap. + const upstream = readSibling("../index.css"); + expect(upstream).toMatch(/--font-sans:\s*var\(\s*--fork-font-sans\s*,/u); + expect(upstream).toMatch(/--font-mono:\s*var\(\s*--fork-font-mono\s*,/u); + }); + + it("keeps Geist Mono ahead of SF Mono, inverting upstream's order", () => { + // Upstream lists SF Mono first, so a bundled mono webfont never renders on + // macOS. Lose this ordering and Geist Mono silently stops appearing. + const block = markerBlock(readSibling("../theme.custom.css")); + const monoStack = block.slice(block.indexOf("--fork-font-mono:")); + const geist = monoStack.indexOf('"Geist Mono Variable"'); + const sfMono = monoStack.indexOf('"SF Mono"'); + expect(geist).toBeGreaterThanOrEqual(0); + expect(sfMono).toBeGreaterThan(geist); + }); + + it("keeps upstream's bundled mono fallback in both stacks", () => { + // JetBrains Mono is upstream's only *bundled* mono face. Dropping it + // regresses a Linux user with no SF Mono and no Consolas to generic + // monospace whenever the Geist Mono fetch fails. + const block = markerBlock(readSibling("../theme.custom.css")); + expect(block.slice(block.indexOf("--fork-font-mono:"))).toContain('"JetBrains Mono"'); + expect(FORK_TERMINAL_FONT_FALLBACK).toContain('"JetBrains Mono"'); + }); + + it("keeps the terminal wired to the fork-owned font module", () => { + const drawer = readSibling("../components/ThreadTerminalDrawer.tsx"); + expect(drawer).toContain("fontFamily: resolveTerminalFontFamily(mount)"); + expect(drawer).toContain("refitTerminalWhenFontsReady({"); + }); + + describe("resolved stack", () => { + it("falls back to a stack that still leads with Geist Mono", () => { + // The degraded path must not land the terminal on SF Mono while the rest + // of the app is on Geist — that split is what the indirection prevents. + expect(terminalFontFamilyFrom(" ")).toBe(FORK_TERMINAL_FONT_FALLBACK); + expect(FORK_TERMINAL_FONT_FALLBACK.startsWith('"Geist Mono Variable"')).toBe(true); + }); + + it("prefers the cascade-resolved value when present", () => { + expect(terminalFontFamilyFrom(' "Geist Mono Variable", monospace ')).toBe( + '"Geist Mono Variable", monospace', + ); + }); + + it("takes the first family for the font-load probe", () => { + expect(firstFontFamily('"Geist Mono Variable", "SF Mono", monospace')).toBe( + '"Geist Mono Variable"', + ); + expect(firstFontFamily("")).toBeNull(); + }); + }); + + describe("cold-load re-measure", () => { + it("probes the resolved family rather than a hardcoded one", async () => { + // Hardcoding goes stale on a face swap, and would fetch Geist Mono even in + // an unmarked build — breaking the fork's own scoping invariant. + const probe = terminalProbe('"Geist Mono Variable", monospace'); + const { requested, fonts } = fakeFonts(); + await refitTerminalWhenFontsReady({ + terminal: probe.target, + isCurrent: () => true, + fit: () => {}, + resize: () => {}, + fonts, + scheduleFrame: (callback) => callback(), + }); + expect(requested).toEqual(['12px "Geist Mono Variable"']); + }); + + it("re-applies the family so xterm re-measures, then tells the PTY", async () => { + // xterm's option setter drops equal writes, so the value has to change + // before it changes back. And nothing in the drawer subscribes to + // onResize: without the resize call the PTY keeps wrapping to the stale + // width while the local grid is corrected. + const probe = terminalProbe('"Geist Mono Variable", monospace'); + const { fonts } = fakeFonts(); + const resized: Array<[number, number]> = []; + let fitted = 0; + + await refitTerminalWhenFontsReady({ + terminal: probe.target, + isCurrent: () => true, + fit: () => { + fitted += 1; + }, + resize: (cols, rows) => resized.push([cols, rows]), + fonts, + scheduleFrame: (callback) => callback(), + }); + + expect(probe.writes.length).toBe(2); + expect(probe.writes[0]).not.toBe(probe.writes[1]); + expect(probe.writes[1]).toBe('"Geist Mono Variable", monospace'); + expect(fitted).toBe(1); + expect(resized).toEqual([[80, 24]]); + expect(probe.scrolls()).toBe(1); + }); + + it("holds the viewport when it was not pinned to the bottom", async () => { + const probe = terminalProbe('"Geist Mono Variable", monospace', false); + const { fonts } = fakeFonts(); + await refitTerminalWhenFontsReady({ + terminal: probe.target, + isCurrent: () => true, + fit: () => {}, + resize: () => {}, + fonts, + scheduleFrame: (callback) => callback(), + }); + expect(probe.scrolls()).toBe(0); + }); + + it("leaves a torn-down terminal alone", async () => { + const probe = terminalProbe('"Geist Mono Variable", monospace'); + const { fonts } = fakeFonts(); + const resized: Array<[number, number]> = []; + await refitTerminalWhenFontsReady({ + terminal: probe.target, + isCurrent: () => false, + fit: () => {}, + resize: (cols, rows) => resized.push([cols, rows]), + fonts, + scheduleFrame: (callback) => callback(), + }); + expect(probe.writes).toEqual([]); + expect(resized).toEqual([]); + }); + + it("survives a webfont that fails to load", async () => { + // FontFaceSet.load() rejects if a matching face fails. That must not + // surface as an unhandled rejection, and the re-fit should still run — + // the fallback metrics are simply the ones that stay correct. + const probe = terminalProbe('"Geist Mono Variable", monospace'); + const { fonts } = fakeFonts(Promise.reject(new Error("404"))); + let fitted = 0; + await refitTerminalWhenFontsReady({ + terminal: probe.target, + isCurrent: () => true, + fit: () => { + fitted += 1; + }, + resize: () => {}, + fonts, + scheduleFrame: (callback) => callback(), + }); + expect(fitted).toBe(1); + }); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71ab..b520826acce2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -34,6 +34,9 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; +/* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ +import { refitTerminalWhenFontsReady, resolveTerminalFontFamily } from "../custom/terminalFont"; +/* fork:end geist-typography */ import { useOpenInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, @@ -391,13 +394,24 @@ export function TerminalViewport({ lineHeight: 1, fontSize: 12, scrollback: 5_000, - fontFamily: - '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', + /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ + fontFamily: resolveTerminalFontFamily(mount), + /* fork:end geist-typography */ theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitTerminalSafely(fitAddon); + /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ + void refitTerminalWhenFontsReady({ + terminal, + // Cleanup nulls the ref before terminal.dispose(), so a late resolve can + // never touch a disposed terminal. + isCurrent: () => terminalRef.current === terminal, + fit: () => fitTerminalSafely(fitAddon), + resize: resizeTerminal, + }); + /* fork:end geist-typography */ terminalRef.current = terminal; fitAddonRef.current = fitAddon; diff --git a/apps/web/src/custom/terminalFont.ts b/apps/web/src/custom/terminalFont.ts new file mode 100644 index 000000000000..dace9a8e0125 --- /dev/null +++ b/apps/web/src/custom/terminalFont.ts @@ -0,0 +1,149 @@ +/** + * Fork-owned terminal typography, Tier 1 — see + * `.fork/customizations.yaml#geist-typography`. + * + * xterm takes its font from a constructor option, not the cascade, so the + * fork's `--font-mono` has to be resolved by hand and re-applied once the + * webfont actually lands. That is the whole reason this module exists. Keeping + * it out of `ThreadTerminalDrawer`'s mount effect leaves that ~1.5k-line + * upstream hot path with two one-line call sites instead of forty lines of + * fork logic, and lets the behaviour be tested without a DOM (the web unit + * project runs on `environment: "node"`). + */ + +/** + * Size for the `FontFaceSet.load()` shorthand. Syntactically required and + * irrelevant to matching — the family selects the face, the size does not. + */ +const FONT_LOAD_PROBE_SIZE = "12px"; + +/** + * Any family other than the one being applied. xterm's option setter drops + * equal writes, so re-applying the resolved stack has to be preceded by a + * different value to register as a change. See `remeasure` below. + */ +const REMEASURE_BOUNCE_FAMILY = "monospace"; + +/** + * Used when the cascade read comes back empty — a detached mount, or the fork + * marker not yet stamped. It leads with Geist Mono on purpose: font fallback + * is per-family and skips families that aren't loaded, so naming the bundled + * face first costs nothing when it is missing, and avoids the degraded path + * quietly landing the terminal on SF Mono while the rest of the app is on + * Geist. JetBrains Mono is kept as upstream's bundled Linux fallback. + */ +export const FORK_TERMINAL_FONT_FALLBACK = + '"Geist Mono Variable", "Geist Mono", "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace'; + +/** Pure half of {@link resolveTerminalFontFamily}, so it can be tested without a DOM. */ +export function terminalFontFamilyFrom(resolvedVariable: string): string { + return resolvedVariable.trim() || FORK_TERMINAL_FONT_FALLBACK; +} + +/** Reads the cascade-resolved `--font-mono` off `element`. */ +export function resolveTerminalFontFamily(element: Element): string { + return terminalFontFamilyFrom(getComputedStyle(element).getPropertyValue("--font-mono")); +} + +/** + * First family of a CSS font stack, for use in a `font` shorthand. Computed + * custom properties preserve the authored text, so a multi-word family arrives + * already quoted. + */ +export function firstFontFamily(stack: string): string | null { + const first = stack.split(",")[0]?.trim(); + return first ? first : null; +} + +/** + * The slice of xterm's `Terminal` this module touches. `fontFamily` is optional + * because `ITerminalOptions` declares it that way. + */ +export interface ForkTerminalFontTarget { + readonly cols: number; + readonly rows: number; + readonly options: { fontFamily?: string | undefined }; + readonly buffer: { readonly active: { readonly viewportY: number; readonly baseY: number } }; + scrollToBottom: () => void; +} + +export interface RefitTerminalWhenFontsReadyArgs { + readonly terminal: ForkTerminalFontTarget; + /** False once the terminal has been torn down, so a late resolve is dropped. */ + readonly isCurrent: () => boolean; + /** Upstream's `fitTerminalSafely(fitAddon)`. */ + readonly fit: () => void; + /** Upstream's `resizeTerminal`, propagating the new geometry to the PTY. */ + readonly resize: (cols: number, rows: number) => unknown; + readonly fonts?: Pick | undefined; + readonly scheduleFrame?: ((callback: () => void) => void) | undefined; +} + +function defaultScheduleFrame(callback: () => void): void { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(callback); + return; + } + callback(); +} + +/** + * xterm sizes its cell grid at `open()`. With a webfont that can happen before + * the face has landed, leaving the grid measured against the fallback metrics — + * measurably so: Geist Mono renders at the unknown-font fallback width until it + * loads, and wider afterwards. This re-measures once the fonts settle and then + * propagates the corrected geometry, both locally and to the PTY. + */ +export async function refitTerminalWhenFontsReady( + args: RefitTerminalWhenFontsReadyArgs, +): Promise { + const fonts = args.fonts ?? globalThis.document?.fonts; + if (!fonts) return; + + // Set from `resolveTerminalFontFamily` at the call site; if some future + // upstream refactor stops passing one there is no resolved stack to restore, + // and xterm's own default already measured correctly. + const fontFamily = args.terminal.options.fontFamily; + if (!fontFamily) return; + + const probe = firstFontFamily(fontFamily); + if (probe) { + // Derived from the family we actually resolved rather than hardcoded, for + // two reasons. It cannot go stale when the mono face changes; and in an + // unmarked, pure-upstream build it names a local system face with no + // `@font-face` rule, so nothing is fetched and the fork's "an unmarked + // build never pulls a Geist byte" invariant holds. + // + // `load()` rejects if a matching face fails to load. An unhandled + // rejection would be pure noise here: a missing webfont just means the + // fallback metrics were already correct. + await fonts.load(`${FONT_LOAD_PROBE_SIZE} ${probe}`).catch(() => []); + } + await fonts.ready; + if (!args.isCurrent()) return; + + // xterm's option setter drops equal writes — `_setupOptions` guards with + // `if (this.rawOptions[propName] !== value)` — so re-assigning the same stack + // fires no change event and nothing re-measures. Bounce through another + // family to force it. Both writes are in one task, so nothing paints between. + args.terminal.options.fontFamily = REMEASURE_BOUNCE_FAMILY; + args.terminal.options.fontFamily = fontFamily; + + // Fit on the next frame rather than inline: that avoids betting on xterm + // re-measuring synchronously inside the setter, and mirrors the frame + // upstream already uses at its `drawerHeight`/`resizeEpoch` fit site. + (args.scheduleFrame ?? defaultScheduleFrame)(() => { + if (!args.isCurrent()) return; + // Mirrors the fit/propagate sequence upstream uses at both of its own fit + // sites: nothing in the drawer subscribes to `onResize`, so the PTY only + // learns the new column count if we tell it. Without this the corrected + // grid is narrower than the width the PTY is still wrapping to. + const active = args.terminal.buffer.active; + const wasAtBottom = active.viewportY >= active.baseY; + args.fit(); + if (wasAtBottom) { + args.terminal.scrollToBottom(); + } + void args.resize(args.terminal.cols, args.terminal.rows); + }); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 387c7a857e3e..338224af2b9d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,11 +122,36 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; - --font-sans: - "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --font-mono: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography + Indirection only; the fork's actual stacks live in `theme.custom.css`. This + block is `@theme inline`, so Tailwind bakes these values *literally* into + `.font-sans`, `.font-mono` and every variant of them (`[&_input]:font-sans` + and friends) — a scoped override of `--font-sans` in the fork stylesheet + cannot reach a compiled utility, the same constraint that keeps the sidebar + palette registrations below in this file. Routing through --fork-font-* + keeps the fork's values scoped under its marker attribute, and the + fallbacks leave an unmarked, pure-upstream build on upstream's stacks. */ + --font-sans: var( + --fork-font-sans, + "DM Sans Variable", + "DM Sans", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + sans-serif + ); + --font-mono: var( + --fork-font-mono, + "SF Mono", + "SFMono-Regular", + "JetBrains Mono", + Consolas, + "Liberation Mono", + Menlo, + monospace + ); + /* fork:end geist-typography */ --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 74301d378c66..28660e61bd07 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -10,6 +10,12 @@ * be scoped to a selector. The fork-owned ones are prefixed `sidebar-v2-rain-*` * so the names cannot collide with upstream's. * + * `@font-face` is the second exception, for the same reason: a face + * *registration* is global — there is no selector to hang it off. What matters + * is that nothing *uses* it outside the marker, and the `--font-*` overrides + * below are scoped, so an unmarked build registers the families and then never + * references them (so the browser never fetches a byte). + * * The Tailwind-facing half of the Sidebar V2 palette — the six * `--color-sidebar-v2-*` registrations — necessarily stays in `index.css`'s * `@theme` block, since that is the only place Tailwind reads utility names @@ -17,6 +23,50 @@ * cascade can carry lives here instead. */ +/* Geist ships as two variable faces: `Geist Variable` (100..900) and + `Geist Mono Variable` (100..900). Both are pulled in here rather than from + `main.tsx` so the fork owns its own font loading and upstream's import block + stays untouched — upstream's DM Sans / JetBrains Mono imports are left in + place for the same reason, and cost nothing on the wire once the overrides + below stop referencing those families. */ +@import "@fontsource-variable/geist/index.css"; +@import "@fontsource-variable/geist-mono/index.css"; + +/* The stacks themselves. `index.css` reads these through + `--font-sans: var(--fork-font-sans, )` inside its + `@theme inline` block, fenced as `fork:geist-typography` — the indirection is + what lets a scoped declaration reach Tailwind's compiled `.font-sans` / + `.font-mono` utilities, which bake their value in literally and would + otherwise ignore anything declared here. An unmarked build never sets these, + so it falls through to upstream's fallbacks. + + Geist Mono is listed AHEAD of SF Mono, inverting upstream's order. Upstream + puts SF Mono first, so on macOS its bundled mono webfont never actually + renders — keeping that order would make shipping Geist Mono pointless. */ +:root[data-fork="noahhendrickson-t3code"] { + --fork-font-sans: + "Geist Variable", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + /* "JetBrains Mono" is kept from upstream's stack: it is the only *bundled* + mono fallback, so dropping it would leave a Linux user with no SF Mono and + no Consolas on Liberation Mono or generic monospace if the Geist Mono fetch + fails, where upstream had a guaranteed face. */ + --fork-font-mono: + "Geist Mono Variable", "Geist Mono", "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, + "Liberation Mono", Menlo, monospace; +} + +/* Upstream repeats the literal stacks on `body` (index.css:964) and `pre, code` + (index.css:1022) instead of reading its own `@theme` tokens, so overriding the + variables alone does not reach them. `:root[data-fork=…]` (0,2,x) outranks + both bare selectors. */ +:root[data-fork="noahhendrickson-t3code"] body { + font-family: var(--font-sans); +} + +:root[data-fork="noahhendrickson-t3code"] :is(pre, code) { + font-family: var(--font-mono); +} + /* Sidebar V2 status palette, keyed to the fork marker so an unmarked build falls back to upstream's variables. The design's hues are tuned for a black panel; on the light panel the same values are near-invisible at 8px, so each diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78ab34d63ced..934a7e35dddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,12 @@ importers: '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/geist': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/geist-mono': + specifier: ^5.3.0 + version: 5.3.0 '@fontsource/jetbrains-mono': specifier: ^5.2.8 version: 5.2.8 @@ -2702,6 +2708,12 @@ packages: '@fontsource-variable/dm-sans@5.2.8': resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} + '@fontsource-variable/geist-mono@5.3.0': + resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==} + + '@fontsource-variable/geist@5.3.0': + resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==} + '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} @@ -12699,6 +12711,10 @@ snapshots: '@fontsource-variable/dm-sans@5.2.8': {} + '@fontsource-variable/geist-mono@5.3.0': {} + + '@fontsource-variable/geist@5.3.0': {} + '@fontsource/jetbrains-mono@5.2.8': {} '@formkit/auto-animate@0.9.0': {}