feat: custom IDE Font (#1428)

* Add an Appearance setting for the app/workbench UI font family. It will be independent from terminal typography and will not change Monaco, diffs, markdown editor text, or terminal panes.

* Update IDE font copy
This commit is contained in:
Kaylee 2026-05-05 00:29:41 -07:00 committed by GitHub
parent 479f5c488d
commit 4497b5fa3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 137 additions and 11 deletions

View File

@ -30,6 +30,7 @@ vi.mock('node:os', async () => {
})
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
@ -100,7 +101,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
...overrides,
appFontFamily
}
}

View File

@ -24,6 +24,7 @@ vi.mock('node:os', async () => {
})
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
@ -94,7 +95,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
...overrides,
appFontFamily
}
}

View File

@ -72,6 +72,7 @@ describe('Store', () => {
expect(settings.branchPrefix).toBe('git-username')
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
expect(settings.theme).toBe('system')
expect(settings.appFontFamily).toBe('Geist')
expect(settings.editorAutoSave).toBe(false)
expect(settings.editorAutoSaveDelayMs).toBe(1000)
expect(settings.terminalFontSize).toBe(14)
@ -310,12 +311,14 @@ describe('Store', () => {
theme: 'dark',
editorAutoSave: true,
editorAutoSaveDelayMs: 1500,
appFontFamily: 'Inter',
terminalFontSize: 16,
terminalFontWeight: 600
})
expect(updated.theme).toBe('dark')
expect(updated.editorAutoSave).toBe(true)
expect(updated.editorAutoSaveDelayMs).toBe(1500)
expect(updated.appFontFamily).toBe('Inter')
expect(updated.terminalFontSize).toBe(16)
expect(updated.terminalFontWeight).toBe(600)
// Other fields preserved

View File

@ -9,6 +9,7 @@ import {
TOGGLE_TERMINAL_PANE_EXPAND_EVENT
} from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { buildAppFontFamily } from '@/lib/app-font-family'
import { toast } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@ -518,6 +519,13 @@ function App(): React.JSX.Element {
}
}, [settings])
useEffect(() => {
document.documentElement.style.setProperty(
'--app-font-family',
buildAppFontFamily(settings?.appFontFamily)
)
}, [settings?.appFontFamily])
// Refresh GitHub data (PR/issue status) when window regains focus
useEffect(() => {
const handler = (): void => {

View File

@ -61,6 +61,8 @@
/* ── Light Mode ──────────────────────────────────────── */
:root {
--app-font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-sans: var(--app-font-family);
--font-mono:
'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono',
monospace;
@ -165,12 +167,14 @@
padding: 0;
overflow: hidden;
height: 100vh;
font-family:
font-family: var(
--app-font-family,
'Geist',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
sans-serif
);
letter-spacing: 0.01em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

View File

@ -5,11 +5,14 @@ import { UIZoomControl } from './UIZoomControl'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { useAppStore } from '../../store'
import { FontAutocomplete } from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
type AppearancePaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
}
const STATUS_BAR_TOGGLES: readonly {
@ -87,6 +90,14 @@ const ZOOM_ENTRIES: SettingsSearchEntry[] = [
}
]
const TYPOGRAPHY_ENTRIES: SettingsSearchEntry[] = [
{
title: 'IDE Font',
description: 'Choose the font used by the Orca interface.',
keywords: ['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']
}
]
const LAYOUT_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Open Right Sidebar by Default',
@ -117,6 +128,7 @@ const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [
export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...THEME_ENTRIES,
...TYPOGRAPHY_ENTRIES,
...ZOOM_ENTRIES,
...LAYOUT_ENTRIES,
...TITLEBAR_ENTRIES,
@ -127,7 +139,8 @@ export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
export function AppearancePane({
settings,
updateSettings,
applyTheme
applyTheme,
fontSuggestions
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isMac = navigator.userAgent.includes('Mac')
@ -191,6 +204,33 @@ export function AppearancePane({
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TYPOGRAPHY_ENTRIES) ? (
<section key="typography" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Typography</h3>
<p className="text-xs text-muted-foreground">
Choose the font used by the Orca interface.
</p>
</div>
<SearchableSetting
title="IDE Font"
description="Choose the font used by the Orca interface."
keywords={['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']}
className="space-y-2"
>
<Label>IDE Font</Label>
<FontAutocomplete
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
/>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, LAYOUT_ENTRIES) ? (
<section key="layout" className="space-y-4">
<div className="space-y-1">

View File

@ -23,6 +23,7 @@ import { useAppStore } from '../../store'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
import { BrowserPane, BROWSER_PANE_SEARCH_ENTRIES } from './BrowserPane'
import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane'
@ -180,8 +181,8 @@ function Settings(): React.JSX.Element {
void window.api.wsl.isAvailable().then(setWslAvailable)
void window.api.pwsh.isAvailable().then(setPwshAvailable)
}, [isWindows])
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
getFallbackTerminalFonts()
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
const [activeSectionId, setActiveSectionId] = useState('general')
// Why: the hidden-experimental group is an unlock — Shift-clicking the
@ -255,7 +256,9 @@ function Settings(): React.JSX.Element {
return
}
terminalFontsLoadedRef.current = true
setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
setFontSuggestions((prev) =>
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320)
)
} catch {
// Fall back to curated cross-platform suggestions.
}
@ -684,6 +687,7 @@ function Settings(): React.JSX.Element {
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
/>
</SettingsSection>
@ -708,7 +712,7 @@ function Settings(): React.JSX.Element {
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
terminalFontSuggestions={fontSuggestions}
scrollbackMode={scrollbackMode}
setScrollbackMode={setScrollbackMode}
ghostty={ghostty}

View File

@ -42,6 +42,7 @@ type FontAutocompleteProps = {
value: string
suggestions: string[]
onChange: (value: string) => void
placeholder?: string
}
export function ThemePicker({
@ -214,7 +215,8 @@ export function NumberField({
export function FontAutocomplete({
value,
suggestions,
onChange
onChange,
placeholder = 'SF Mono'
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
const [prevValue, setPrevValue] = useState(value)
@ -353,7 +355,7 @@ export function FontAutocomplete({
}
}
}}
placeholder="SF Mono"
placeholder={placeholder}
className="pr-18"
role="combobox"
aria-autocomplete="list"

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { buildAppFontFamily } from './app-font-family'
describe('buildAppFontFamily', () => {
it('defaults to the bundled app font', () => {
expect(buildAppFontFamily('')).toBe(
'"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
)
})
it('places a custom UI font before the fallback chain', () => {
expect(buildAppFontFamily('Inter')).toBe(
'"Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
)
})
it('does not duplicate the bundled font when selected explicitly', () => {
expect(buildAppFontFamily('Geist')).toBe(
'"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
)
})
})

View File

@ -0,0 +1,36 @@
import { DEFAULT_APP_FONT_FAMILY } from '../../../shared/constants'
const APP_FONT_FALLBACKS = [
DEFAULT_APP_FONT_FAMILY,
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'sans-serif'
] as const
const CSS_FONT_KEYWORDS = new Set([
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'blinkmacsystemfont'
])
function quoteFontFamily(fontFamily: string): string {
if (fontFamily.startsWith('-') || CSS_FONT_KEYWORDS.has(fontFamily.toLowerCase())) {
return fontFamily
}
return JSON.stringify(fontFamily)
}
export function buildAppFontFamily(fontFamily: string | null | undefined): string {
const trimmed = fontFamily?.trim() || DEFAULT_APP_FONT_FAMILY
const lowerTrimmed = trimmed.toLowerCase()
const parts = [
trimmed,
...APP_FONT_FALLBACKS.filter((fallback) => fallback.toLowerCase() !== lowerTrimmed)
]
return parts.map(quoteFontFamily).join(', ')
}

View File

@ -11,6 +11,7 @@ import type {
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
export const SCHEMA_VERSION = 1
export const DEFAULT_APP_FONT_FAMILY = 'Geist'
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// Why: blank browser tabs must start from an inert guest URL that does not
@ -119,6 +120,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
appFontFamily: DEFAULT_APP_FONT_FAMILY,
editorAutoSave: false,
editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS,
editorMinimapEnabled: false,

View File

@ -1020,6 +1020,7 @@ export type GlobalSettings = {
branchPrefixCustom: string
enableGitHubAttribution: boolean
theme: 'system' | 'dark' | 'light'
appFontFamily: string
editorAutoSave: boolean
editorAutoSaveDelayMs: number
editorMinimapEnabled: boolean