From f3b00d0f78d93da5b6e48075033a14bc8c97a337 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Fri, 16 Jan 2026 23:54:35 +0100 Subject: [PATCH] feat: add global font settings with per-project override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fontFamilySans and fontFamilyMono to GlobalSettings type - Add global font state and actions to app store - Update getEffectiveFontSans/Mono to fall back to global settings - Add font selectors to global Settings → Appearance - Add "Use Global Font" checkboxes in Project Settings → Theme - Add fonts to settings sync and migration - Include fonts in import/export JSON --- .../project-theme-section.tsx | 223 ++++++++++++------ .../appearance/appearance-section.tsx | 99 +++++++- apps/ui/src/hooks/use-settings-migration.ts | 2 + apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/store/app-store.ts | 32 ++- libs/types/src/settings.ts | 6 + 6 files changed, 287 insertions(+), 77 deletions(-) diff --git a/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx index dca0f7b2..2ec89498 100644 --- a/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-theme-section.tsx @@ -26,15 +26,19 @@ interface ProjectThemeSectionProps { export function ProjectThemeSection({ project }: ProjectThemeSectionProps) { const { theme: globalTheme, + fontFamilySans: globalFontSans, + fontFamilyMono: globalFontMono, setProjectTheme, setProjectFontSans, setProjectFontMono, } = useAppStore(); const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); - const [fontSans, setFontSansLocal] = useState( + + // Font local state - tracks what's selected when using custom fonts + const [fontSansLocal, setFontSansLocal] = useState( project.fontFamilySans || DEFAULT_FONT_VALUE ); - const [fontMono, setFontMonoLocal] = useState( + const [fontMonoLocal, setFontMonoLocal] = useState( project.fontFamilyMono || DEFAULT_FONT_VALUE ); @@ -44,38 +48,80 @@ export function ProjectThemeSection({ project }: ProjectThemeSectionProps) { setFontMonoLocal(project.fontFamilyMono || DEFAULT_FONT_VALUE); }, [project]); + // Theme state const projectTheme = project.theme as Theme | undefined; const hasCustomTheme = projectTheme !== undefined; const effectiveTheme = projectTheme || globalTheme; + // Font state - check if project has custom fonts set + const hasCustomFontSans = project.fontFamilySans !== undefined; + const hasCustomFontMono = project.fontFamilyMono !== undefined; + const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; + // Theme handlers const handleThemeChange = (theme: Theme) => { setProjectTheme(project.id, theme); }; const handleUseGlobalTheme = (checked: boolean) => { if (checked) { - // Clear project theme to use global setProjectTheme(project.id, null); } else { - // Set project theme to current global theme setProjectTheme(project.id, globalTheme); } }; + // Font handlers + const handleUseGlobalFontSans = (checked: boolean) => { + if (checked) { + // Clear project font to use global + setProjectFontSans(project.id, null); + setFontSansLocal(DEFAULT_FONT_VALUE); + } else { + // Set to current global font or default + const fontToSet = globalFontSans || DEFAULT_FONT_VALUE; + setFontSansLocal(fontToSet); + setProjectFontSans(project.id, fontToSet === DEFAULT_FONT_VALUE ? null : fontToSet); + } + }; + + const handleUseGlobalFontMono = (checked: boolean) => { + if (checked) { + // Clear project font to use global + setProjectFontMono(project.id, null); + setFontMonoLocal(DEFAULT_FONT_VALUE); + } else { + // Set to current global font or default + const fontToSet = globalFontMono || DEFAULT_FONT_VALUE; + setFontMonoLocal(fontToSet); + setProjectFontMono(project.id, fontToSet === DEFAULT_FONT_VALUE ? null : fontToSet); + } + }; + const handleFontSansChange = (value: string) => { setFontSansLocal(value); - // 'default' means use theme default, so we pass null to clear the override setProjectFontSans(project.id, value === DEFAULT_FONT_VALUE ? null : value); }; const handleFontMonoChange = (value: string) => { setFontMonoLocal(value); - // 'default' means use theme default, so we pass null to clear the override setProjectFontMono(project.id, value === DEFAULT_FONT_VALUE ? null : value); }; + // Get display label for global font + const getGlobalFontSansLabel = () => { + if (!globalFontSans) return 'Default (Geist Sans)'; + const option = UI_SANS_FONT_OPTIONS.find((o) => o.value === globalFontSans); + return option?.label || globalFontSans; + }; + + const getGlobalFontMonoLabel = () => { + if (!globalFontMono) return 'Default (Geist Mono)'; + const option = UI_MONO_FONT_OPTIONS.find((o) => o.value === globalFontMono); + return option?.label || globalFontMono; + }; + return (
-

Theme

+

Theme & Fonts

- Customize the theme for this project. + Customize the appearance for this project.

@@ -206,67 +252,112 @@ export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
-

- Override the default fonts for this project. Fonts must be installed on your system. -

-
- {/* UI Font Selector */} -
- - -

- Used for headings, labels, and UI text -

+
+ {/* UI Font */} +
+
+ +
+ + {!hasCustomFontSans && ( +

+ Currently using:{' '} + {getGlobalFontSansLabel()} +

+ )} +
+
+ + {hasCustomFontSans && ( +
+ + +
+ )}
- {/* Code Font Selector */} -
- - -

- Used for code blocks and monospaced text -

+ {/* Code Font */} +
+
+ +
+ + {!hasCustomFontMono && ( +

+ Currently using:{' '} + {getGlobalFontMonoLabel()} +

+ )} +
+
+ + {hasCustomFontMono && ( +
+ + +
+ )}
diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 47646287..651231dd 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,8 +1,21 @@ import { useState } from 'react'; import { Label } from '@/components/ui/label'; -import { Palette, Moon, Sun } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Palette, Moon, Sun, Type } from 'lucide-react'; import { darkThemes, lightThemes } from '@/config/theme-options'; +import { + UI_SANS_FONT_OPTIONS, + UI_MONO_FONT_OPTIONS, + DEFAULT_FONT_VALUE, +} from '@/config/ui-font-options'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; import type { Theme } from '../shared/types'; interface AppearanceSectionProps { @@ -12,9 +25,22 @@ interface AppearanceSectionProps { export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) { const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark'); + const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore(); const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes; + // Convert null to 'default' for Select component + const fontSansValue = fontFamilySans || DEFAULT_FONT_VALUE; + const fontMonoValue = fontFamilyMono || DEFAULT_FONT_VALUE; + + const handleFontSansChange = (value: string) => { + setFontSans(value === DEFAULT_FONT_VALUE ? null : value); + }; + + const handleFontMonoChange = (value: string) => { + setFontMono(value === DEFAULT_FONT_VALUE ? null : value); + }; + return (
+ + {/* Fonts Section */} +
+
+ + +
+

+ Set default fonts for all projects. Individual projects can override these settings. +

+ +
+ {/* UI Font Selector */} +
+ + +

+ Used for headings, labels, and UI text +

+
+ + {/* Code Font Selector */} +
+ + +

+ Used for code blocks and monospaced text +

+
+
+
); diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 79135bb2..836daf15 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -556,6 +556,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { useAppStore.setState({ theme: settings.theme as unknown as import('@/store/app-store').ThemeMode, + fontFamilySans: settings.fontFamilySans ?? null, + fontFamilyMono: settings.fontFamilyMono ?? null, sidebarOpen: settings.sidebarOpen ?? true, chatHistoryOpen: settings.chatHistoryOpen ?? false, maxConcurrency: settings.maxConcurrency ?? 3, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index ca6c62c7..763c2bb0 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -33,6 +33,8 @@ const SYNC_DEBOUNCE_MS = 1000; // Fields to sync to server (subset of AppState that should be persisted) const SETTINGS_FIELDS_TO_SYNC = [ 'theme', + 'fontFamilySans', + 'fontFamilyMono', 'sidebarOpen', 'chatHistoryOpen', 'maxConcurrency', diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 18ff5040..c154ef39 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -510,6 +510,10 @@ export interface AppState { // Theme theme: ThemeMode; + // Fonts (global defaults) + fontFamilySans: string | null; // null = use default Geist Sans + fontFamilyMono: string | null; // null = use default Geist Mono + // Features/Kanban features: Feature[]; @@ -920,11 +924,13 @@ export interface AppActions { getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set) setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear) - // Font actions (per-project) - setProjectFontSans: (projectId: string, fontFamily: string | null) => void; // Set per-project UI/sans font (null to clear) - setProjectFontMono: (projectId: string, fontFamily: string | null) => void; // Set per-project code/mono font (null to clear) - getEffectiveFontSans: () => string | null; // Get effective UI font (project override or null for default) - getEffectiveFontMono: () => string | null; // Get effective code font (project override or null for default) + // Font actions (global + per-project override) + setFontSans: (fontFamily: string | null) => void; // Set global UI/sans font (null to clear) + setFontMono: (fontFamily: string | null) => void; // Set global code/mono font (null to clear) + setProjectFontSans: (projectId: string, fontFamily: string | null) => void; // Set per-project UI/sans font override (null = use global) + setProjectFontMono: (projectId: string, fontFamily: string | null) => void; // Set per-project code/mono font override (null = use global) + getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default) + getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default) // Feature actions setFeatures: (features: Feature[]) => void; @@ -1264,6 +1270,8 @@ const initialState: AppState = { mobileSidebarHidden: false, // Sidebar visible by default on mobile lastSelectedSessionByProject: {}, theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark' + fontFamilySans: null, // Global UI font (null = use default Geist Sans) + fontFamilyMono: null, // Global code font (null = use default Geist Mono) features: [], appSpec: '', ipcConnected: false, @@ -1739,7 +1747,11 @@ export const useAppStore = create()((set, get) => ({ setPreviewTheme: (theme) => set({ previewTheme: theme }), - // Font actions (per-project) + // Font actions (global + per-project override) + setFontSans: (fontFamily) => set({ fontFamilySans: fontFamily }), + + setFontMono: (fontFamily) => set({ fontFamilyMono: fontFamily }), + setProjectFontSans: (projectId, fontFamily) => { // Update the project's fontFamilySans property const projects = get().projects.map((p) => @@ -1784,20 +1796,20 @@ export const useAppStore = create()((set, get) => ({ getEffectiveFontSans: () => { const currentProject = get().currentProject; - // Return the project's font override, or null for default + // Return project override if set, otherwise global, otherwise null for default if (currentProject?.fontFamilySans) { return currentProject.fontFamilySans; } - return null; + return get().fontFamilySans; }, getEffectiveFontMono: () => { const currentProject = get().currentProject; - // Return the project's font override, or null for default + // Return project override if set, otherwise global, otherwise null for default if (currentProject?.fontFamilyMono) { return currentProject.fontFamilyMono; } - return null; + return get().fontFamilyMono; }, // Feature actions diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 734f08a2..06670cc6 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -463,6 +463,12 @@ export interface GlobalSettings { /** Currently selected theme */ theme: ThemeMode; + // Font Configuration + /** Global UI/Sans font family (undefined = use default Geist Sans) */ + fontFamilySans?: string; + /** Global Code/Mono font family (undefined = use default Geist Mono) */ + fontFamilyMono?: string; + // UI State Preferences /** Whether sidebar is currently open */ sidebarOpen: boolean;