diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8399055b7..9bb7f354f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -30,6 +30,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -100,7 +101,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 51de5333e..2ef8ccba9 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async () => { }) function createSettings(overrides: Partial = {}): GlobalSettings { + const appFontFamily = overrides.appFontFamily ?? 'Geist' return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -94,7 +95,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, - ...overrides + ...overrides, + appFontFamily } } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c810e9977..2bed14f0a 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 62fca2a1c..0d68326a5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 964d39195..f986c18c9 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 4642d5508..290abf983 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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) => 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({ ) : null, + matchesSettingsSearch(searchQuery, TYPOGRAPHY_ENTRIES) ? ( +
+
+

Typography

+

+ Choose the font used by the Orca interface. +

+
+ + + + + updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) + } + /> + +
+ ) : null, matchesSettingsSearch(searchQuery, LAYOUT_ENTRIES) ? (
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 1611c4f4d..429132be9 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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( - getFallbackTerminalFonts() + const [fontSuggestions, setFontSuggestions] = useState( + 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} /> @@ -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} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index b1ad300f1..737b5cb4e 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -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" diff --git a/src/renderer/src/lib/app-font-family.test.ts b/src/renderer/src/lib/app-font-family.test.ts new file mode 100644 index 000000000..3a23f8992 --- /dev/null +++ b/src/renderer/src/lib/app-font-family.test.ts @@ -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' + ) + }) +}) diff --git a/src/renderer/src/lib/app-font-family.ts b/src/renderer/src/lib/app-font-family.ts new file mode 100644 index 000000000..956f478e0 --- /dev/null +++ b/src/renderer/src/lib/app-font-family.ts @@ -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(', ') +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 2d59a9e18..879ab9c3d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 061b2c9d4..3e236ff65 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1020,6 +1020,7 @@ export type GlobalSettings = { branchPrefixCustom: string enableGitHubAttribution: boolean theme: 'system' | 'dark' | 'light' + appFontFamily: string editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean