From 2377298b42750c58b5ef2945d8f8323b6a3b5a88 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 10:39:53 -0400 Subject: [PATCH 1/4] feat(fork): swap the web typeface to Geist and Geist Mono MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces DM Sans with Geist for UI text and puts Geist Mono ahead of SF Mono for code, bundled as Fontsource variable faces. Web and Electron renderer only — mobile and marketing still ship DM Sans. The stacks live in the fork's own theme layer 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 rather than ceremony: the block is @theme inline, so Tailwind bakes font values literally into .font-sans / .font-mono and every variant of them, and a compiled utility cannot see a variable overridden by a later stylesheet. Overriding --font-sans alone left those four selectors on DM Sans while the rest of the app moved. Routing through --fork-font-* keeps the values scoped under the fork marker, and the fallbacks leave an unmarked, pure-upstream build on upstream's stacks. body and pre, code are re-declared in the fork layer because upstream hardcodes the literal stacks on those selectors instead of reading its own @theme tokens. Geist Mono deliberately precedes SF Mono, inverting upstream's order: upstream puts SF Mono first, so on macOS a bundled mono webfont never renders at all, which would have made shipping Geist Mono pointless. The terminal resolves --font-mono by hand, since xterm reads its font from an option rather than the cascade, and re-measures its cell grid once the face lands. This is not theoretical — Geist Mono measures at exactly the unknown-font fallback width before it loads and 21px wider after, so without the re-measure a cold open bakes the wrong column count into the grid. DM Sans and JetBrains Mono deps and their main.tsx imports are left in place on purpose: an unreferenced family is never fetched, so removing them would be an upstream edit for no gain. Co-Authored-By: Claude Opus 5 (1M context) --- .fork/customizations.yaml | 38 ++++++++ apps/web/package.json | 2 + .../__fork_guards__/geistTypography.test.ts | 95 +++++++++++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 34 ++++++- apps/web/src/index.css | 35 ++++++- apps/web/src/theme.custom.css | 46 +++++++++ pnpm-lock.yaml | 16 ++++ 7 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/__fork_guards__/geistTypography.test.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 2803cdf3421c..f61950a21184 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -273,3 +273,41 @@ - 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. + 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. The + terminal resolves --font-mono by hand at mount, since xterm reads its font + from an option rather than the cascade, and re-measures its cell grid on + document.fonts.ready — 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. DM Sans / JetBrains Mono deps and their + main.tsx imports are deliberately left in place: an unreferenced family is + never fetched, so removing them would be an upstream edit for no gain. + tier: 4 + files: + - apps/web/src/theme.custom.css + shadows: [] + watch: + - apps/web/src/index.css + - apps/web/src/components/ThreadTerminalDrawer.tsx + 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..5e701663af29 --- /dev/null +++ b/apps/web/src/__fork_guards__/geistTypography.test.ts @@ -0,0 +1,95 @@ +// @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. + */ + +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"; + +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}"]`; + +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 scoped to the fork marker", () => { + const theme = readSibling("../theme.custom.css"); + const block = theme.slice(theme.indexOf(`${MARKER} {`)); + 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 theme = readSibling("../theme.custom.css"); + const monoStack = theme.slice(theme.indexOf("--fork-font-mono:")); + expect(monoStack.indexOf('"Geist Mono Variable"')).toBeGreaterThanOrEqual(0); + expect(monoStack.indexOf('"Geist Mono Variable"')).toBeLessThan(monoStack.indexOf('"SF Mono"')); + }); + + it("re-declares the selectors where upstream hardcodes a font literal", () => { + // `body` and `pre, code` in index.css name the stacks literally instead of + // reading the @theme tokens, so the variable override alone misses them. + const theme = readSibling("../theme.custom.css"); + expect(theme).toContain(`${MARKER} body`); + expect(theme).toContain(`${MARKER} :is(pre, code)`); + }); + + it("gives xterm the resolved --font-mono instead of a hardcoded stack", () => { + const drawer = readSibling("../components/ThreadTerminalDrawer.tsx"); + expect(drawer).toContain('getPropertyValue("--font-mono")'); + expect(drawer).toContain("fontFamily: terminalFontFamily"); + // Quoted, so this matches a CSS family literal rather than any passing + // mention of the name in prose. + expect(drawer).not.toContain('"JetBrains Mono"'); + expect(drawer).not.toContain('"DM Sans"'); + }); + + it("keeps the terminal's webfont re-measure hook", () => { + // Nothing else fails if this goes: the terminal just opens with its column + // count measured against the fallback face on a cold load. + const drawer = readSibling("../components/ThreadTerminalDrawer.tsx"); + expect(drawer).toContain("document.fonts.load"); + expect(drawer).toContain("document.fonts.ready"); + expect(drawer).toContain("terminal.options.fontFamily = terminalFontFamily"); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71ab..a734cd417ead 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -386,18 +386,48 @@ export function TerminalViewport({ const localApi = readLocalApi(); const fitAddon = new FitAddon(); + /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ + // xterm reads its font from this option only — no CSS inheritance — so the + // fork's --font-mono has to be resolved by hand. The literal fallback + // covers an unmarked build where the variable is somehow unset, and names + // system faces only — no bundled family, since none is guaranteed loaded. + const terminalFontFamily = + getComputedStyle(mount).getPropertyValue("--font-mono").trim() || + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; + /* fork:end geist-typography */ const terminal = new Terminal({ cursorBlink: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, - fontFamily: - '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: terminalFontFamily, theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitTerminalSafely(fitAddon); + /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ + // Geist Mono is a webfont; xterm sizes its cell grid at open(). If the face + // is still in flight we just measured the fallback, so re-measure once it + // lands. fonts.load() forces the fetch to start — open() mounting DOM with + // the family should have started it already, but that leans on xterm's + // char-measure element being a real DOM node, an internal worth not + // depending on. fonts.ready then awaits everything pending, including the + // other unicode-range subsets an already-painted glyph may have triggered. + // Reassigning fontFamily is what triggers the re-measure, bounced through a + // throwaway value so it registers as a change regardless of how xterm's + // options setter treats equal writes; both writes are in one task, so + // nothing paints in between. + void document.fonts.load('12px "Geist Mono Variable"'); + void document.fonts.ready.then(() => { + // Cleanup nulls the ref before terminal.dispose(), so this can never + // touch a disposed terminal. + if (terminalRef.current !== terminal) return; + terminal.options.fontFamily = "monospace"; + terminal.options.fontFamily = terminalFontFamily; + fitTerminalSafely(fitAddon); + }); + /* fork:end geist-typography */ terminalRef.current = terminal; fitAddonRef.current = fitAddon; 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..3585acfe298a 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,46 @@ * 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; + --fork-font-mono: + "Geist Mono Variable", "Geist Mono", "SF Mono", "SFMono-Regular", 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': {} From 7fa08d70d8024e4121e68fd37a89bb86b383ea71 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 11:00:07 -0400 Subject: [PATCH 2/4] fix(fork): move terminal font logic into the fork layer and tell the PTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the re-fit corrected xterm's local grid and stopped there. Nothing in ThreadTerminalDrawer subscribes to onResize — every upstream fit site calls resizeTerminal by hand — so on a cold load the mount timer and the resizeEpoch effect would report the fallback-measured column count to the PTY, then the font-driven re-fit would silently disagree with it. Since Geist Mono is wider than the fallback, the corrected grid has fewer columns than the PTY believes: the classic over-wrap, persisting until an unrelated re-fit. The re-fit now mirrors upstream's own fit/propagate sequence, including the wasAtBottom/scrollToBottom pairing it was missing. The logic moves out of the drawer's ~1.5k-line mount effect into custom/terminalFont.ts, leaving two one-line call sites. That is where fork feature logic belongs per the placement ladder, and it makes the behaviour testable: the web unit project runs on environment "node", so the module splits its pure parts from its DOM parts and takes fonts/scheduleFrame as injectable seams. Seven behaviour tests replace the source-string greps that previously pinned this code's placement while asserting nothing about what it does. Also from review: - The fonts.load() probe derives from the resolved stack instead of a hardcoded "Geist Mono Variable". Hardcoding went stale on a face swap, and fired unconditionally — fetching Geist Mono in an unmarked build, breaking the very scoping invariant theme.custom.css documents. In an unmarked build the derived family names a local face with no @font-face rule, so nothing is fetched. - load() rejections are caught. Per spec it rejects when a matching face fails, and `void` left that as an unhandled rejection on any 404'd woff2. - The re-fit happens in a requestAnimationFrame rather than betting on xterm re-measuring synchronously inside its option setter, matching the frame upstream already uses at its resizeEpoch fit site. - The drawer's degraded fallback stack now leads with Geist Mono. It named system faces only, so every failure mode landed the terminal on SF Mono while the rest of the app was on Geist — the exact split the indirection exists to prevent. Font fallback is per-family and skips what is absent, so naming the bundled face first costs nothing. - "JetBrains Mono" is kept in the mono stack. It is upstream's only bundled mono face; dropping it regressed a Linux user with no SF Mono and no Consolas to generic monospace whenever the Geist fetch fails. - The scoping guard now bounds its slice to the marker block. It sliced to end of file, so it would have passed on a declaration that lost its scoping entirely — the one thing the test is named for. - annotationTheme.ts and package.json join watch: the former is the dynamic seam that ships the resolved families into the previewed page (inert today, but not fenced and not watched); the latter because detect-drift greps fences in ts/css/yaml/sh only and cannot see a dependency change. Verified in the running app: PTY and xterm agree on 161 columns, and the terminal still renders Geist Mono after the extraction. Co-Authored-By: Claude Opus 5 (1M context) --- .fork/customizations.yaml | 20 ++ .../__fork_guards__/geistTypography.test.ts | 232 ++++++++++++++++-- .../src/components/ThreadTerminalDrawer.tsx | 42 +--- apps/web/src/custom/terminalFont.ts | 149 +++++++++++ apps/web/src/theme.custom.css | 8 +- 5 files changed, 393 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/custom/terminalFont.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index f61950a21184..9e505991a5f1 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -288,6 +288,15 @@ 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 re-applied once the + webfont lands, and that is fork feature logic with no business in a 1.5k-line + upstream mount effect. The drawer keeps two one-line call sites. 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 @@ -305,9 +314,20 @@ 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/src/__fork_guards__/geistTypography.test.ts b/apps/web/src/__fork_guards__/geistTypography.test.ts index 5e701663af29..7b395a6c5306 100644 --- a/apps/web/src/__fork_guards__/geistTypography.test.ts +++ b/apps/web/src/__fork_guards__/geistTypography.test.ts @@ -7,6 +7,12 @@ * 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"; @@ -14,6 +20,13 @@ 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"); @@ -21,6 +34,61 @@ function readSibling(relativePath: string): string { 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 { @@ -36,9 +104,10 @@ describe("fork guard: geist-typography", () => { expect(theme).toContain('@import "@fontsource-variable/geist-mono/index.css"'); }); - it("declares the Geist stacks scoped to the fork marker", () => { - const theme = readSibling("../theme.custom.css"); - const block = theme.slice(theme.indexOf(`${MARKER} {`)); + 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:"); @@ -60,36 +129,145 @@ describe("fork guard: geist-typography", () => { 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 theme = readSibling("../theme.custom.css"); - const monoStack = theme.slice(theme.indexOf("--fork-font-mono:")); - expect(monoStack.indexOf('"Geist Mono Variable"')).toBeGreaterThanOrEqual(0); - expect(monoStack.indexOf('"Geist Mono Variable"')).toBeLessThan(monoStack.indexOf('"SF Mono"')); + 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("re-declares the selectors where upstream hardcodes a font literal", () => { - // `body` and `pre, code` in index.css name the stacks literally instead of - // reading the @theme tokens, so the variable override alone misses them. - const theme = readSibling("../theme.custom.css"); - expect(theme).toContain(`${MARKER} body`); - expect(theme).toContain(`${MARKER} :is(pre, code)`); + 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("gives xterm the resolved --font-mono instead of a hardcoded stack", () => { + it("keeps the terminal wired to the fork-owned font module", () => { const drawer = readSibling("../components/ThreadTerminalDrawer.tsx"); - expect(drawer).toContain('getPropertyValue("--font-mono")'); - expect(drawer).toContain("fontFamily: terminalFontFamily"); - // Quoted, so this matches a CSS family literal rather than any passing - // mention of the name in prose. - expect(drawer).not.toContain('"JetBrains Mono"'); - expect(drawer).not.toContain('"DM Sans"'); + expect(drawer).toContain("fontFamily: resolveTerminalFontFamily(mount)"); + expect(drawer).toContain("refitTerminalWhenFontsReady({"); }); - it("keeps the terminal's webfont re-measure hook", () => { - // Nothing else fails if this goes: the terminal just opens with its column - // count measured against the fallback face on a cold load. - const drawer = readSibling("../components/ThreadTerminalDrawer.tsx"); - expect(drawer).toContain("document.fonts.load"); - expect(drawer).toContain("document.fonts.ready"); - expect(drawer).toContain("terminal.options.fontFamily = terminalFontFamily"); + 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 a734cd417ead..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, @@ -386,46 +389,27 @@ export function TerminalViewport({ const localApi = readLocalApi(); const fitAddon = new FitAddon(); - /* fork:begin geist-typography — see .fork/customizations.yaml#geist-typography */ - // xterm reads its font from this option only — no CSS inheritance — so the - // fork's --font-mono has to be resolved by hand. The literal fallback - // covers an unmarked build where the variable is somehow unset, and names - // system faces only — no bundled family, since none is guaranteed loaded. - const terminalFontFamily = - getComputedStyle(mount).getPropertyValue("--font-mono").trim() || - '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; - /* fork:end geist-typography */ const terminal = new Terminal({ cursorBlink: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, - fontFamily: terminalFontFamily, + /* 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 */ - // Geist Mono is a webfont; xterm sizes its cell grid at open(). If the face - // is still in flight we just measured the fallback, so re-measure once it - // lands. fonts.load() forces the fetch to start — open() mounting DOM with - // the family should have started it already, but that leans on xterm's - // char-measure element being a real DOM node, an internal worth not - // depending on. fonts.ready then awaits everything pending, including the - // other unicode-range subsets an already-painted glyph may have triggered. - // Reassigning fontFamily is what triggers the re-measure, bounced through a - // throwaway value so it registers as a change regardless of how xterm's - // options setter treats equal writes; both writes are in one task, so - // nothing paints in between. - void document.fonts.load('12px "Geist Mono Variable"'); - void document.fonts.ready.then(() => { - // Cleanup nulls the ref before terminal.dispose(), so this can never - // touch a disposed terminal. - if (terminalRef.current !== terminal) return; - terminal.options.fontFamily = "monospace"; - terminal.options.fontFamily = terminalFontFamily; - fitTerminalSafely(fitAddon); + 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 */ 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/theme.custom.css b/apps/web/src/theme.custom.css index 3585acfe298a..28660e61bd07 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -46,9 +46,13 @@ :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", Consolas, "Liberation Mono", - Menlo, monospace; + "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` From 9b27b8a2e4b1612eeb1a8bf5d15905c2d8d59727 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 12:49:44 -0400 Subject: [PATCH 3/4] docs(fork): correct the geist-typography intent's dependency reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review established that "an unreferenced family is never fetched, so removing them would be an upstream edit for no gain" is wrong: nothing references DM Sans or JetBrains Mono in a marked build, but main.tsx still imports their Fontsource CSS, so Vite emits the woff2 files into dist and into the Electron package. The tradeoff is installer size, not request count. The PR body was corrected; the manifest was not, and the manifest is what a future sync reads. Also folds away a paragraph duplicated when the terminal-extraction text was grafted on, and records the derived font-load probe — a review outcome the intent never captured. Co-Authored-By: Claude Opus 5 --- .fork/customizations.yaml | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 9e505991a5f1..bee0955e8b1b 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -290,9 +290,14 @@ 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 re-applied once the - webfont lands, and that is fork feature logic with no business in a 1.5k-line - upstream mount effect. The drawer keeps two one-line call sites. The re-fit + 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 @@ -303,14 +308,14 @@ 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. The - terminal resolves --font-mono by hand at mount, since xterm reads its font - from an option rather than the cascade, and re-measures its cell grid on - document.fonts.ready — 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. DM Sans / JetBrains Mono deps and their - main.tsx imports are deliberately left in place: an unreferenced family is - never fetched, so removing them would be an upstream edit for no gain. + 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. Nothing + references those families in a marked build, so they already cost nothing + on the wire — however main.tsx still imports their Fontsource CSS, so Vite + emits their woff2 files into dist and into the Electron package. The + tradeoff for removing them is installer size, not request count. Left as-is + to keep main.tsx conflict-free; worth revisiting if package size matters. tier: 4 files: - apps/web/src/theme.custom.css From 1e7195c8a1ae776262da723cdcc68ceb1727aed0 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 12:54:44 -0400 Subject: [PATCH 4/4] docs(fork): separate the two dependency cases in the geist-typography intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous correction was right that "removing them would gain nothing" was wrong, but its replacement flattened two different cases into one. "Nothing references those families in a marked build" holds for DM Sans and not for JetBrains Mono: the commit before it put "JetBrains Mono" back into --fork-font-mono and FORK_TERMINAL_FONT_FALLBACK on purpose, as upstream's only bundled mono face, for a Linux user with no SF Mono and no Consolas. It is referenced in a marked build and is fetched precisely when the Geist Mono fetch fails — the case it was kept for. So "the tradeoff for removing them is installer size" is true of DM Sans and misleading about JetBrains Mono, in the document a future sync reads. A maintainer trimming dependencies on size grounds would strip the face out from under a stack that still names it, and no guard would fail: the guards assert the family is listed, not that a bundled face backs it. Says so now, and keeps the measured sizes (~60KB / ~96KB) that make the DM Sans case concrete. Co-Authored-By: Claude Opus 5 (1M context) --- .fork/customizations.yaml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index bee0955e8b1b..6f49f03f0eb6 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -310,12 +310,20 @@ (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. Nothing - references those families in a marked build, so they already cost nothing - on the wire — however main.tsx still imports their Fontsource CSS, so Vite - emits their woff2 files into dist and into the Electron package. The - tradeoff for removing them is installer size, not request count. Left as-is - to keep main.tsx conflict-free; worth revisiting if package size matters. + 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