feat: Add UI scale feature with 75-200% range (#125)

* feat: add UI scale feature

* refactor: extract UI scale bounds to shared constants

* fix: duplicated import
This commit is contained in:
Enes Cingöz
2025-12-23 11:30:32 +00:00
committed by GitHub
parent 30e7536b63
commit 6951251b33
8 changed files with 249 additions and 13 deletions
+1 -1
View File
@@ -98,7 +98,7 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.22",
"electron": "^39.2.6",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
+9 -1
View File
@@ -55,7 +55,7 @@ import { useTaskStore, loadTasks } from './stores/task-store';
import { useSettingsStore, loadSettings } from './stores/settings-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
import { useIpcListeners } from './hooks/useIpc';
import { COLOR_THEMES } from '../shared/constants';
import { COLOR_THEMES, UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT } from '../shared/constants';
import type { Task, Project, ColorTheme } from '../shared/types';
import { ProjectTabBar } from './components/ProjectTabBar';
@@ -357,6 +357,14 @@ export function App() {
};
}, [settings.theme, settings.colorTheme]);
// Apply UI scale
useEffect(() => {
const root = document.documentElement;
const scale = settings.uiScale ?? UI_SCALE_DEFAULT;
const clampedScale = Math.max(UI_SCALE_MIN, Math.min(UI_SCALE_MAX, scale));
root.setAttribute('data-ui-scale', clampedScale.toString());
}, [settings.uiScale]);
// Update selected task when tasks change (for real-time updates)
useEffect(() => {
if (selectedTask) {
@@ -13,7 +13,8 @@ import {
Zap,
Github,
Database,
Sparkles
Sparkles,
Monitor
} from 'lucide-react';
import {
FullScreenDialog,
@@ -29,6 +30,7 @@ import { ScrollArea } from '../ui/scroll-area';
import { cn } from '../../lib/utils';
import { useSettings } from './hooks/useSettings';
import { ThemeSettings } from './ThemeSettings';
import { DisplaySettings } from './DisplaySettings';
import { GeneralSettings } from './GeneralSettings';
import { IntegrationSettings } from './IntegrationSettings';
import { AdvancedSettings } from './AdvancedSettings';
@@ -46,7 +48,7 @@ interface AppSettingsDialogProps {
}
// App-level settings sections
export type AppSection = 'appearance' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications';
export type AppSection = 'appearance' | 'display' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications';
interface NavItem<T extends string> {
id: T;
@@ -57,6 +59,7 @@ interface NavItem<T extends string> {
const appNavItems: NavItem<AppSection>[] = [
{ id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and visual preferences' },
{ id: 'display', label: 'Display', icon: Monitor, description: 'UI scale and zoom' },
{ id: 'agent', label: 'Agent Settings', icon: Bot, description: 'Default model and framework' },
{ id: 'paths', label: 'Paths', icon: FolderOpen, description: 'Python and framework paths' },
{ id: 'integrations', label: 'Integrations', icon: Key, description: 'API keys & Claude accounts' },
@@ -157,6 +160,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
switch (appSection) {
case 'appearance':
return <ThemeSettings settings={settings} onSettingsChange={setSettings} />;
case 'display':
return <DisplaySettings settings={settings} onSettingsChange={setSettings} />;
case 'agent':
return <GeneralSettings settings={settings} onSettingsChange={setSettings} section="agent" />;
case 'paths':
@@ -0,0 +1,163 @@
import { Monitor, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Label } from '../ui/label';
import { SettingsSection } from './SettingsSection';
import { useSettingsStore } from '../../stores/settings-store';
import { UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT, UI_SCALE_STEP } from '../../../shared/constants';
import type { AppSettings } from '../../../shared/types';
interface DisplaySettingsProps {
settings: AppSettings;
onSettingsChange: (settings: AppSettings) => void;
}
// Preset scale values (100%, 125%, 150%)
const SCALE_PRESETS = [
{ value: UI_SCALE_DEFAULT, label: '100%', description: 'Default' },
{ value: 125, label: '125%', description: 'Comfortable' },
{ value: 150, label: '150%', description: 'Large' }
] as const;
/**
* Display settings section for UI scale/zoom control
* Provides preset buttons (100%, 125%, 150%) and a fine-tune slider (75-200%)
* Changes apply immediately for live preview (like theme), saved on "Save Settings"
*/
export function DisplaySettings({ settings, onSettingsChange }: DisplaySettingsProps) {
const updateStoreSettings = useSettingsStore((state) => state.updateSettings);
const currentScale = settings.uiScale ?? UI_SCALE_DEFAULT;
const handleScaleChange = (newScale: number) => {
// Clamp to valid range
const clampedScale = Math.max(UI_SCALE_MIN, Math.min(UI_SCALE_MAX, newScale));
// Update local draft state
onSettingsChange({ ...settings, uiScale: clampedScale });
// Apply immediately to store for live preview (triggers App.tsx useEffect)
updateStoreSettings({ uiScale: clampedScale });
};
const handleReset = () => {
handleScaleChange(UI_SCALE_DEFAULT);
};
return (
<SettingsSection
title="Display"
description="Adjust the size of UI elements"
>
<div className="space-y-6">
{/* Preset Buttons */}
<div className="space-y-3">
<Label className="text-sm font-medium text-foreground">Scale Presets</Label>
<p className="text-sm text-muted-foreground">
Quick scale options for common preferences
</p>
<div className="grid grid-cols-3 gap-3 max-w-md pt-1">
{SCALE_PRESETS.map((preset) => {
const isSelected = currentScale === preset.value;
return (
<button
key={preset.value}
onClick={() => handleScaleChange(preset.value)}
className={cn(
'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-accent/50'
)}
>
<Monitor className="h-4 w-4" />
<div className="text-center">
<div className="text-sm font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.description}</div>
</div>
</button>
);
})}
</div>
</div>
{/* Fine-tune Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Fine-tune Scale</Label>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-muted-foreground">
{currentScale}%
</span>
{currentScale !== UI_SCALE_DEFAULT && (
<button
onClick={handleReset}
className={cn(
'p-1.5 rounded-md transition-colors',
'hover:bg-accent text-muted-foreground hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
title="Reset to default (100%)"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<p className="text-sm text-muted-foreground">
Adjust from {UI_SCALE_MIN}% to {UI_SCALE_MAX}% in {UI_SCALE_STEP}% increments
</p>
{/* Slider with icons */}
<div className="flex items-center gap-3 pt-1">
<ZoomOut className="h-4 w-4 text-muted-foreground shrink-0" />
<input
type="range"
min={UI_SCALE_MIN}
max={UI_SCALE_MAX}
step={UI_SCALE_STEP}
value={currentScale}
onChange={(e) => handleScaleChange(parseInt(e.target.value, 10))}
className={cn(
'flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
// Webkit (Chrome, Safari, Edge)
'[&::-webkit-slider-thumb]:appearance-none',
'[&::-webkit-slider-thumb]:w-4',
'[&::-webkit-slider-thumb]:h-4',
'[&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-primary',
'[&::-webkit-slider-thumb]:cursor-pointer',
'[&::-webkit-slider-thumb]:transition-all',
'[&::-webkit-slider-thumb]:hover:scale-110',
// Firefox
'[&::-moz-range-thumb]:w-4',
'[&::-moz-range-thumb]:h-4',
'[&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-primary',
'[&::-moz-range-thumb]:border-0',
'[&::-moz-range-thumb]:cursor-pointer',
'[&::-moz-range-thumb]:transition-all',
'[&::-moz-range-thumb]:hover:scale-110'
)}
/>
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
{/* Scale markers */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>{UI_SCALE_MIN}%</span>
<span>{UI_SCALE_MAX}%</span>
</div>
</div>
{/* Preview hint */}
<div className="rounded-lg bg-muted/50 border border-border p-4 text-sm">
<p className="text-muted-foreground">
Changes preview immediately. Click <strong className="text-foreground">Save Settings</strong> to persist your preferences.
</p>
</div>
</div>
</SettingsSection>
);
}
@@ -1,13 +1,14 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSettingsStore, saveSettings as saveSettingsToStore, loadSettings as loadSettingsFromStore } from '../../../stores/settings-store';
import type { AppSettings } from '../../../../shared/types';
import { UI_SCALE_DEFAULT } from '../../../../shared/constants';
/**
* Custom hook for managing application settings
* Provides state management and save/load functionality
*
* Theme changes are applied immediately for live preview. If the user cancels
* without saving, call revertTheme() to restore the original theme.
* Theme and UI scale changes are applied immediately for live preview. If the user
* cancels without saving, call revertTheme() to restore the original values.
*/
export function useSettings() {
const currentSettings = useSettingsStore((state) => state.settings);
@@ -18,9 +19,14 @@ export function useSettings() {
// Store the original theme settings when the hook mounts (dialog opens)
// This allows us to revert if the user cancels
const originalThemeRef = useRef<{ theme: AppSettings['theme']; colorTheme: AppSettings['colorTheme'] }>({
const originalThemeRef = useRef<{
theme: AppSettings['theme'];
colorTheme: AppSettings['colorTheme'];
uiScale: number;
}>({
theme: currentSettings.theme,
colorTheme: currentSettings.colorTheme
colorTheme: currentSettings.colorTheme,
uiScale: currentSettings.uiScale ?? UI_SCALE_DEFAULT
});
// Sync with store
@@ -34,7 +40,8 @@ export function useSettings() {
// Update the original theme ref when settings load
originalThemeRef.current = {
theme: currentSettings.theme,
colorTheme: currentSettings.colorTheme
colorTheme: currentSettings.colorTheme,
uiScale: currentSettings.uiScale ?? UI_SCALE_DEFAULT
};
}, []);
@@ -87,7 +94,8 @@ export function useSettings() {
const original = originalThemeRef.current;
updateStoreSettings({
theme: original.theme,
colorTheme: original.colorTheme
colorTheme: original.colorTheme,
uiScale: original.uiScale
});
}, [updateStoreSettings]);
@@ -98,9 +106,10 @@ export function useSettings() {
const commitTheme = useCallback(() => {
originalThemeRef.current = {
theme: settings.theme,
colorTheme: settings.colorTheme
colorTheme: settings.colorTheme,
uiScale: settings.uiScale ?? UI_SCALE_DEFAULT
};
}, [settings.theme, settings.colorTheme]);
}, [settings.theme, settings.colorTheme, settings.uiScale]);
return {
settings,
@@ -1651,3 +1651,41 @@ body {
[data-state="closed"].animate-out.zoom-out-98 {
animation: zoom-out-98 0.2s ease-in;
}
/* ============================================
UI Scale System (75% - 200%)
============================================ */
/* Explicit base font size */
:root {
font-size: 16px;
transition: font-size 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Scale levels via data-ui-scale attribute */
[data-ui-scale="75"] { font-size: 12px; }
[data-ui-scale="80"] { font-size: 12.8px; }
[data-ui-scale="85"] { font-size: 13.6px; }
[data-ui-scale="90"] { font-size: 14.4px; }
[data-ui-scale="95"] { font-size: 15.2px; }
[data-ui-scale="100"] { font-size: 16px; }
[data-ui-scale="105"] { font-size: 16.8px; }
[data-ui-scale="110"] { font-size: 17.6px; }
[data-ui-scale="115"] { font-size: 18.4px; }
[data-ui-scale="120"] { font-size: 19.2px; }
[data-ui-scale="125"] { font-size: 20px; }
[data-ui-scale="130"] { font-size: 20.8px; }
[data-ui-scale="135"] { font-size: 21.6px; }
[data-ui-scale="140"] { font-size: 22.4px; }
[data-ui-scale="145"] { font-size: 23.2px; }
[data-ui-scale="150"] { font-size: 24px; }
[data-ui-scale="155"] { font-size: 24.8px; }
[data-ui-scale="160"] { font-size: 25.6px; }
[data-ui-scale="165"] { font-size: 26.4px; }
[data-ui-scale="170"] { font-size: 27.2px; }
[data-ui-scale="175"] { font-size: 28px; }
[data-ui-scale="180"] { font-size: 28.8px; }
[data-ui-scale="185"] { font-size: 29.6px; }
[data-ui-scale="190"] { font-size: 30.4px; }
[data-ui-scale="195"] { font-size: 31.2px; }
[data-ui-scale="200"] { font-size: 32px; }
+12 -1
View File
@@ -3,6 +3,15 @@
* Default settings, file paths, and project structure
*/
// ============================================
// UI Scale Constants
// ============================================
export const UI_SCALE_MIN = 75;
export const UI_SCALE_MAX = 200;
export const UI_SCALE_DEFAULT = 100;
export const UI_SCALE_STEP = 5;
// ============================================
// Default App Settings
// ============================================
@@ -31,7 +40,9 @@ export const DEFAULT_APP_SETTINGS = {
// Changelog preferences (persisted between sessions)
changelogFormat: 'keep-a-changelog' as const,
changelogAudience: 'user-facing' as const,
changelogEmojiLevel: 'none' as const
changelogEmojiLevel: 'none' as const,
// UI Scale (default 100% - standard size)
uiScale: UI_SCALE_DEFAULT
};
// ============================================
@@ -106,6 +106,8 @@ export interface AppSettings {
changelogFormat?: ChangelogFormat;
changelogAudience?: ChangelogAudience;
changelogEmojiLevel?: ChangelogEmojiLevel;
// UI Scale setting (75-200%, default 100)
uiScale?: number;
// Migration flags (internal use)
_migratedAgentProfileToAuto?: boolean;
}