feat: add per-project font override settings

Add font selectors that allow per-project font customization for both
sans and mono fonts, independent of theme selection. Uses system fonts.

- Add fontFamilySans and fontFamilyMono to ProjectSettings and Project types
- Create ui-font-options.ts config with system font options
- Add store actions: setProjectFontSans, setProjectFontMono, getEffectiveFontSans, getEffectiveFontMono
- Apply font CSS variables in root component
- Add font selector UI in project-theme-section (Project Settings → Theme)
This commit is contained in:
Stefan de Vogelaere
2026-01-16 23:36:56 +01:00
parent aa35eb3d3a
commit 1322722db2
8 changed files with 263 additions and 1027 deletions

View File

@@ -1,8 +1,16 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
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, type Theme } from '@/config/theme-options';
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
@@ -12,8 +20,21 @@ interface ProjectThemeSectionProps {
}
export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
const { theme: globalTheme, setProjectTheme } = useAppStore();
const {
theme: globalTheme,
setProjectTheme,
setProjectFontSans,
setProjectFontMono,
} = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [fontSans, setFontSansLocal] = useState<string>(project.fontFamilySans || '');
const [fontMono, setFontMonoLocal] = useState<string>(project.fontFamilyMono || '');
// Sync font state when project changes
useEffect(() => {
setFontSansLocal(project.fontFamilySans || '');
setFontMonoLocal(project.fontFamilyMono || '');
}, [project]);
const projectTheme = project.theme as Theme | undefined;
const hasCustomTheme = projectTheme !== undefined;
@@ -35,6 +56,18 @@ export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
}
};
const handleFontSansChange = (value: string) => {
setFontSansLocal(value);
// Empty string means default, so we pass null to clear the override
setProjectFontSans(project.id, value || null);
};
const handleFontMonoChange = (value: string) => {
setFontMonoLocal(value);
// Empty string means default, so we pass null to clear the override
setProjectFontMono(project.id, value || null);
};
return (
<div
className={cn(
@@ -158,6 +191,63 @@ export function ProjectThemeSection({ project }: ProjectThemeSectionProps) {
</p>
</div>
)}
{/* Fonts Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">
<Type className="w-4 h-4 text-muted-foreground" />
<Label className="text-foreground font-medium">Fonts</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2 mb-4">
Override the default fonts for this project. Fonts must be installed on your system.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* UI Font Selector */}
<div className="space-y-2">
<Label htmlFor="ui-font-select" className="text-sm">
UI Font
</Label>
<Select value={fontSans} onValueChange={handleFontSansChange}>
<SelectTrigger id="ui-font-select" className="w-full">
<SelectValue placeholder="Default (Geist Sans)" />
</SelectTrigger>
<SelectContent>
{UI_SANS_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value || 'default-sans'} value={option.value}>
<span style={{ fontFamily: option.value || undefined }}>{option.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Used for headings, labels, and UI text
</p>
</div>
{/* Code Font Selector */}
<div className="space-y-2">
<Label htmlFor="code-font-select" className="text-sm">
Code Font
</Label>
<Select value={fontMono} onValueChange={handleFontMonoChange}>
<SelectTrigger id="code-font-select" className="w-full">
<SelectValue placeholder="Default (Geist Mono)" />
</SelectTrigger>
<SelectContent>
{UI_MONO_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value || 'default-mono'} value={option.value}>
<span style={{ fontFamily: option.value || undefined }}>{option.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Used for code blocks and monospaced text
</p>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -26,6 +26,8 @@ export interface Project {
name: string;
path: string;
theme?: string;
fontFamilySans?: string;
fontFamilyMono?: string;
icon?: string;
customIconPath?: string;
}

View File

@@ -0,0 +1,54 @@
/**
* Font options for per-project font customization
*
* These are system fonts (no bundled @fontsource packages required).
* Users must have the fonts installed on their system for them to work.
*/
export interface UIFontOption {
value: string; // CSS font-family value (empty string means "use default")
label: string; // Display label for the dropdown
}
/**
* Sans/UI fonts for headings, labels, and general text
*
* Empty value means "use the theme default" (Geist Sans for all themes)
*/
export const UI_SANS_FONT_OPTIONS: readonly UIFontOption[] = [
{ value: '', label: 'Default (Geist Sans)' },
{ value: "'Inter', system-ui, sans-serif", label: 'Inter' },
{ value: "'SF Pro', system-ui, sans-serif", label: 'SF Pro' },
{ value: "'Source Sans 3', system-ui, sans-serif", label: 'Source Sans' },
{ value: "'IBM Plex Sans', system-ui, sans-serif", label: 'IBM Plex Sans' },
{ value: "'Roboto', system-ui, sans-serif", label: 'Roboto' },
{ value: 'system-ui, sans-serif', label: 'System Default' },
] as const;
/**
* Mono/code fonts for code blocks, terminals, and monospaced text
*
* Empty value means "use the theme default" (Geist Mono for all themes)
*/
export const UI_MONO_FONT_OPTIONS: readonly UIFontOption[] = [
{ value: '', label: 'Default (Geist Mono)' },
{ value: "'JetBrains Mono', monospace", label: 'JetBrains Mono' },
{ value: "'Fira Code', monospace", label: 'Fira Code' },
{ value: "'SF Mono', Menlo, Monaco, monospace", label: 'SF Mono' },
{ value: "'Source Code Pro', monospace", label: 'Source Code Pro' },
{ value: "'IBM Plex Mono', monospace", label: 'IBM Plex Mono' },
{ value: "Menlo, Monaco, 'Courier New', monospace", label: 'Menlo / Monaco' },
{ value: "'Cascadia Code', monospace", label: 'Cascadia Code' },
] as const;
/**
* Get the display label for a font value
*/
export function getFontLabel(
fontValue: string | undefined,
options: readonly UIFontOption[]
): string {
if (!fontValue) return options[0].label;
const option = options.find((o) => o.value === fontValue);
return option?.label ?? fontValue;
}

View File

@@ -3287,6 +3287,8 @@ export interface Project {
path: string;
lastOpened?: string;
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
fontFamilySans?: string; // Per-project UI/sans font override
fontFamilyMono?: string; // Per-project code/mono font override
isFavorite?: boolean; // Pin project to top of dashboard
icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/

View File

@@ -158,6 +158,8 @@ function RootLayoutContent() {
projectHistory,
upsertAndSetCurrentProject,
getEffectiveTheme,
getEffectiveFontSans,
getEffectiveFontMono,
skipSandboxWarning,
setSkipSandboxWarning,
fetchCodexModels,
@@ -248,6 +250,10 @@ function RootLayoutContent() {
// Defer the theme value to keep UI responsive during rapid hover changes
const deferredTheme = useDeferredValue(effectiveTheme);
// Get effective fonts for the current project
const effectiveFontSans = getEffectiveFontSans();
const effectiveFontMono = getEffectiveFontMono();
useEffect(() => {
setIsMounted(true);
}, []);
@@ -727,6 +733,23 @@ function RootLayoutContent() {
}
}, [deferredTheme]);
// Apply font CSS variables for project-specific font overrides
useEffect(() => {
const root = document.documentElement;
if (effectiveFontSans) {
root.style.setProperty('--font-sans', effectiveFontSans);
} else {
root.style.removeProperty('--font-sans');
}
if (effectiveFontMono) {
root.style.setProperty('--font-mono', effectiveFontMono);
} else {
root.style.removeProperty('--font-mono');
}
}, [effectiveFontSans, effectiveFontMono]);
// Show sandbox rejection screen if user denied the risk warning
if (sandboxStatus === 'denied') {
return <SandboxRejectionScreen />;

View File

@@ -920,6 +920,12 @@ 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)
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
@@ -1733,6 +1739,67 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Font actions (per-project)
setProjectFontSans: (projectId, fontFamily) => {
// Update the project's fontFamilySans property
const projects = get().projects.map((p) =>
p.id === projectId
? { ...p, fontFamilySans: fontFamily === null ? undefined : fontFamily }
: p
);
set({ projects });
// Also update currentProject if it's the same project
const currentProject = get().currentProject;
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
fontFamilySans: fontFamily === null ? undefined : fontFamily,
},
});
}
},
setProjectFontMono: (projectId, fontFamily) => {
// Update the project's fontFamilyMono property
const projects = get().projects.map((p) =>
p.id === projectId
? { ...p, fontFamilyMono: fontFamily === null ? undefined : fontFamily }
: p
);
set({ projects });
// Also update currentProject if it's the same project
const currentProject = get().currentProject;
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
fontFamilyMono: fontFamily === null ? undefined : fontFamily,
},
});
}
},
getEffectiveFontSans: () => {
const currentProject = get().currentProject;
// Return the project's font override, or null for default
if (currentProject?.fontFamilySans) {
return currentProject.fontFamilySans;
}
return null;
},
getEffectiveFontMono: () => {
const currentProject = get().currentProject;
// Return the project's font override, or null for default
if (currentProject?.fontFamilyMono) {
return currentProject.fontFamilyMono;
}
return null;
},
// Feature actions
setFeatures: (features) => set({ features }),

View File

@@ -718,6 +718,12 @@ export interface ProjectSettings {
/** Project theme (undefined = use global setting) */
theme?: ThemeMode;
// Font Configuration (project-specific override)
/** UI/Sans font family override (undefined = use default Geist Sans) */
fontFamilySans?: string;
/** Code/Mono font family override (undefined = use default Geist Mono) */
fontFamilyMono?: string;
// Worktree Management
/** Project-specific worktree preference override */
useWorktrees?: boolean;

1040
package-lock.json generated

File diff suppressed because it is too large Load Diff