diff --git a/src/App.tsx b/src/App.tsx index d5edc031..5e50071a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { useSettingsPluginActions } from "@/hooks/app/use-settings-plugin-action import { useSettingsPluginList } from "@/hooks/app/use-settings-plugin-list" import { useSettingsSystemActions } from "@/hooks/app/use-settings-system-actions" import { useSettingsTheme } from "@/hooks/app/use-settings-theme" +import { useSettingsUIScale } from "@/hooks/app/use-settings-ui-scale" import { useTrayIcon } from "@/hooks/app/use-tray-icon" import { track } from "@/lib/analytics" import { REFRESH_COOLDOWN_MS, savePluginSettings } from "@/lib/settings" @@ -58,6 +59,8 @@ function App() { setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, + uiScale, + setUIScale, } = useAppPreferencesStore( useShallow((state) => ({ autoUpdateInterval: state.autoUpdateInterval, @@ -72,6 +75,8 @@ function App() { setResetTimerDisplayMode: state.setResetTimerDisplayMode, setGlobalShortcut: state.setGlobalShortcut, setStartOnLogin: state.setStartOnLogin, + uiScale: state.uiScale, + setUIScale: state.setUIScale, })) ) @@ -120,12 +125,14 @@ function App() { setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, + setUIScale, setLoadingForPlugins, setErrorForPlugins, startBatch, }) useSettingsTheme(themeMode) + useSettingsUIScale(uiScale) const { handleThemeModeChange, @@ -133,12 +140,14 @@ function App() { handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handleUIScaleChange, } = useSettingsDisplayActions({ setThemeMode, setDisplayMode, resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setUIScale, scheduleTrayIconUpdate, }) @@ -250,6 +259,7 @@ function App() { traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, + onUIScaleChange: handleUIScaleChange, }} /> ) diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index e362fa76..cdd4bceb 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -14,6 +14,7 @@ import type { MenubarIconStyle, ResetTimerDisplayMode, ThemeMode, + UIScale, } from "@/lib/settings" type AppContentDerivedProps = { @@ -35,6 +36,7 @@ export type AppContentActionProps = { traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void + onUIScaleChange: (value: UIScale) => void } export type AppContentProps = AppContentDerivedProps & AppContentActionProps @@ -55,6 +57,7 @@ export function AppContent({ traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, + onUIScaleChange, }: AppContentProps) { const { activeView } = useAppUiStore( useShallow((state) => ({ @@ -70,6 +73,7 @@ export function AppContent({ globalShortcut, themeMode, startOnLogin, + uiScale, } = useAppPreferencesStore( useShallow((state) => ({ displayMode: state.displayMode, @@ -79,6 +83,7 @@ export function AppContent({ globalShortcut: state.globalShortcut, themeMode: state.themeMode, startOnLogin: state.startOnLogin, + uiScale: state.uiScale, })) ) @@ -115,6 +120,8 @@ export function AppContent({ onGlobalShortcutChange={onGlobalShortcutChange} startOnLogin={startOnLogin} onStartOnLoginChange={onStartOnLoginChange} + uiScale={uiScale} + onUIScaleChange={onUIScaleChange} /> ) } diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 9eae9816..5dfcd26f 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -17,6 +17,7 @@ const { loadResetTimerDisplayModeMock, loadStartOnLoginMock, loadThemeModeMock, + loadUIScaleMock, migrateLegacyTraySettingsMock, normalizePluginSettingsMock, savePluginSettingsMock, @@ -36,6 +37,7 @@ const { loadResetTimerDisplayModeMock: vi.fn(), loadStartOnLoginMock: vi.fn(), loadThemeModeMock: vi.fn(), + loadUIScaleMock: vi.fn(), migrateLegacyTraySettingsMock: vi.fn(), normalizePluginSettingsMock: vi.fn(), savePluginSettingsMock: vi.fn(), @@ -61,6 +63,7 @@ vi.mock("@/lib/settings", () => ({ DEFAULT_RESET_TIMER_DISPLAY_MODE: "relative", DEFAULT_START_ON_LOGIN: false, DEFAULT_THEME_MODE: "system", + DEFAULT_UI_SCALE: "normal", getEnabledPluginIds: getEnabledPluginIdsMock, loadAutoUpdateInterval: loadAutoUpdateIntervalMock, loadDisplayMode: loadDisplayModeMock, @@ -70,6 +73,7 @@ vi.mock("@/lib/settings", () => ({ loadResetTimerDisplayMode: loadResetTimerDisplayModeMock, loadStartOnLogin: loadStartOnLoginMock, loadThemeMode: loadThemeModeMock, + loadUIScale: loadUIScaleMock, migrateLegacyTraySettings: migrateLegacyTraySettingsMock, normalizePluginSettings: normalizePluginSettingsMock, savePluginSettings: savePluginSettingsMock, @@ -88,6 +92,7 @@ function createArgs() { setGlobalShortcut: vi.fn(), setStartOnLogin: vi.fn(), setMenubarIconStyle: vi.fn(), + setUIScale: vi.fn(), setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), @@ -111,6 +116,7 @@ describe("useSettingsBootstrap", () => { loadResetTimerDisplayModeMock.mockReset() loadStartOnLoginMock.mockReset() loadThemeModeMock.mockReset() + loadUIScaleMock.mockReset() migrateLegacyTraySettingsMock.mockReset() normalizePluginSettingsMock.mockReset() savePluginSettingsMock.mockReset() @@ -137,6 +143,7 @@ describe("useSettingsBootstrap", () => { loadGlobalShortcutMock.mockResolvedValue("CommandOrControl+Shift+O") loadMenubarIconStyleMock.mockResolvedValue("provider") loadStartOnLoginMock.mockResolvedValue(true) + loadUIScaleMock.mockResolvedValue("normal") migrateLegacyTraySettingsMock.mockResolvedValue(undefined) savePluginSettingsMock.mockResolvedValue(undefined) getEnabledPluginIdsMock.mockReturnValue(["codex"]) @@ -152,6 +159,25 @@ describe("useSettingsBootstrap", () => { expect(enableAutostartMock).not.toHaveBeenCalled() }) + it("falls back to default UI scale when loading fails", async () => { + const uiScaleError = new Error("ui scale unavailable") + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + loadUIScaleMock.mockRejectedValueOnce(uiScaleError) + const args = createArgs() + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => { + expect(errorSpy).toHaveBeenCalledWith( + "Failed to load UI scale:", + uiScaleError + ) + expect(args.setUIScale).toHaveBeenCalledWith("normal") + }) + + errorSpy.mockRestore() + }) + it("falls back to default reset timer mode when loading fails", async () => { const resetModeError = new Error("reset timer mode unavailable") const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index fcc7df09..0e7c9c6e 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -9,6 +9,7 @@ import type { PluginMeta } from "@/lib/plugin-types" import { arePluginSettingsEqual, DEFAULT_AUTO_UPDATE_INTERVAL, + DEFAULT_UI_SCALE, DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_MENUBAR_ICON_STYLE, @@ -17,6 +18,7 @@ import { DEFAULT_THEME_MODE, getEnabledPluginIds, loadAutoUpdateInterval, + loadUIScale, loadDisplayMode, loadGlobalShortcut, loadMenubarIconStyle, @@ -34,6 +36,7 @@ import { type PluginSettings, type ResetTimerDisplayMode, type ThemeMode, + type UIScale, } from "@/lib/settings" type UseSettingsBootstrapArgs = { @@ -46,6 +49,7 @@ type UseSettingsBootstrapArgs = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setUIScale: (value: UIScale) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise @@ -61,6 +65,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setStartOnLogin, setMenubarIconStyle, + setUIScale, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -153,6 +158,13 @@ export function useSettingsBootstrap({ console.error("Failed to load menubar icon style:", error) } + let storedUIScale = DEFAULT_UI_SCALE + try { + storedUIScale = await loadUIScale() + } catch (error) { + console.error("Failed to load UI scale:", error) + } + if (isMounted) { setPluginSettings(normalized) setAutoUpdateInterval(storedInterval) @@ -162,6 +174,7 @@ export function useSettingsBootstrap({ setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) setMenubarIconStyle(storedMenubarIconStyle) + setUIScale(storedUIScale) const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) @@ -187,6 +200,7 @@ export function useSettingsBootstrap({ }, [ applyStartOnLogin, setAutoUpdateInterval, + setUIScale, setDisplayMode, setErrorForPlugins, setGlobalShortcut, diff --git a/src/hooks/app/use-settings-display-actions.test.ts b/src/hooks/app/use-settings-display-actions.test.ts index 7d0db6a4..b0898dbc 100644 --- a/src/hooks/app/use-settings-display-actions.test.ts +++ b/src/hooks/app/use-settings-display-actions.test.ts @@ -6,11 +6,13 @@ const { saveDisplayModeMock, saveResetTimerDisplayModeMock, saveThemeModeMock, + saveUIScaleMock, } = vi.hoisted(() => ({ trackMock: vi.fn(), saveThemeModeMock: vi.fn(), saveDisplayModeMock: vi.fn(), saveResetTimerDisplayModeMock: vi.fn(), + saveUIScaleMock: vi.fn(), })) vi.mock("@/lib/analytics", () => ({ @@ -21,6 +23,7 @@ vi.mock("@/lib/settings", () => ({ saveThemeMode: saveThemeModeMock, saveDisplayMode: saveDisplayModeMock, saveResetTimerDisplayMode: saveResetTimerDisplayModeMock, + saveUIScale: saveUIScaleMock, })) import { useSettingsDisplayActions } from "@/hooks/app/use-settings-display-actions" @@ -31,15 +34,18 @@ describe("useSettingsDisplayActions", () => { saveThemeModeMock.mockReset() saveDisplayModeMock.mockReset() saveResetTimerDisplayModeMock.mockReset() + saveUIScaleMock.mockReset() saveThemeModeMock.mockResolvedValue(undefined) saveDisplayModeMock.mockResolvedValue(undefined) saveResetTimerDisplayModeMock.mockResolvedValue(undefined) + saveUIScaleMock.mockResolvedValue(undefined) }) it("tracks and applies display-related setting changes", () => { const setThemeMode = vi.fn() const setDisplayMode = vi.fn() const setResetTimerDisplayMode = vi.fn() + const setUIScale = vi.fn() const scheduleTrayIconUpdate = vi.fn() const { result } = renderHook(() => @@ -48,6 +54,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode, resetTimerDisplayMode: "relative", setResetTimerDisplayMode, + setUIScale, scheduleTrayIconUpdate, }) ) @@ -88,6 +95,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: mode, setResetTimerDisplayMode, + setUIScale: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }), { initialProps: { mode: "relative" as const } } @@ -120,6 +128,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: "relative", setResetTimerDisplayMode: vi.fn(), + setUIScale: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }) ) @@ -138,4 +147,30 @@ describe("useSettingsDisplayActions", () => { errorSpy.mockRestore() }) + + it("tracks and persists UI scale change", async () => { + const setUIScale = vi.fn() + + const { result } = renderHook(() => + useSettingsDisplayActions({ + setThemeMode: vi.fn(), + setDisplayMode: vi.fn(), + resetTimerDisplayMode: "relative", + setResetTimerDisplayMode: vi.fn(), + setUIScale, + scheduleTrayIconUpdate: vi.fn(), + }) + ) + + act(() => { + result.current.handleUIScaleChange("compact") + }) + + expect(trackMock).toHaveBeenCalledWith("setting_changed", { setting: "ui_scale", value: "compact" }) + expect(setUIScale).toHaveBeenCalledWith("compact") + + await waitFor(() => { + expect(saveUIScaleMock).toHaveBeenCalledWith("compact") + }) + }) }) diff --git a/src/hooks/app/use-settings-display-actions.ts b/src/hooks/app/use-settings-display-actions.ts index 65dcc886..05504681 100644 --- a/src/hooks/app/use-settings-display-actions.ts +++ b/src/hooks/app/use-settings-display-actions.ts @@ -1,6 +1,7 @@ import { useCallback } from "react" import { track } from "@/lib/analytics" import { + saveUIScale, saveDisplayMode, saveMenubarIconStyle, saveResetTimerDisplayMode, @@ -9,6 +10,7 @@ import { type MenubarIconStyle, type ResetTimerDisplayMode, type ThemeMode, + type UIScale, } from "@/lib/settings" type ScheduleTrayIconUpdate = (reason: "probe" | "settings" | "init", delayMs?: number) => void @@ -19,6 +21,7 @@ type UseSettingsDisplayActionsArgs = { resetTimerDisplayMode: ResetTimerDisplayMode setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setUIScale: (value: UIScale) => void scheduleTrayIconUpdate: ScheduleTrayIconUpdate } @@ -28,6 +31,7 @@ export function useSettingsDisplayActions({ resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setUIScale, scheduleTrayIconUpdate, }: UseSettingsDisplayActionsArgs) { const handleThemeModeChange = useCallback((mode: ThemeMode) => { @@ -69,11 +73,20 @@ export function useSettingsDisplayActions({ }) }, [scheduleTrayIconUpdate, setMenubarIconStyle]) + const handleUIScaleChange = useCallback((value: UIScale) => { + track("setting_changed", { setting: "ui_scale", value }) + setUIScale(value) + void saveUIScale(value).catch((error) => { + console.error("Failed to save UI scale:", error) + }) + }, [setUIScale]) + return { handleThemeModeChange, handleDisplayModeChange, handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handleUIScaleChange, } } diff --git a/src/hooks/app/use-settings-ui-scale.ts b/src/hooks/app/use-settings-ui-scale.ts new file mode 100644 index 00000000..4163e329 --- /dev/null +++ b/src/hooks/app/use-settings-ui-scale.ts @@ -0,0 +1,10 @@ +import { useEffect } from "react" +import type { UIScale } from "@/lib/settings" + +export function useSettingsUIScale(uiScale: UIScale) { + useEffect(() => { + const root = document.documentElement + root.classList.remove("small", "compact") + if (uiScale !== "normal") root.classList.add(uiScale) + }, [uiScale]) +} diff --git a/src/index.css b/src/index.css index ec8200cc..ec6c06bb 100644 --- a/src/index.css +++ b/src/index.css @@ -140,6 +140,14 @@ } } +/* UI scale: reduce font size and spacing globally */ +html.small { + font-size: 14px; +} +html.compact { + font-size: 11px; +} + /* Panel-specific styles for transparent Tauri window */ html, body, diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a94d0a7c..c087d6d6 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -33,6 +33,7 @@ const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle"; const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage"; const GLOBAL_SHORTCUT_KEY = "globalShortcut"; const START_ON_LOGIN_KEY = "startOnLogin"; +const UI_SCALE_KEY = "uiScale"; export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateIntervalMinutes = 15; export const DEFAULT_THEME_MODE: ThemeMode = "system"; @@ -41,6 +42,14 @@ export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative export const DEFAULT_MENUBAR_ICON_STYLE: MenubarIconStyle = "provider"; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = false; +export type UIScale = "normal" | "small" | "compact"; +export const DEFAULT_UI_SCALE: UIScale = "normal"; +const UI_SCALE_VALUES: UIScale[] = ["normal", "small", "compact"]; +export const UI_SCALE_OPTIONS: { value: UIScale; label: string }[] = [ + { value: "normal", label: "Normal" }, + { value: "small", label: "Small" }, + { value: "compact", label: "Compact" }, +]; const AUTO_UPDATE_INTERVALS: AutoUpdateIntervalMinutes[] = [5, 15, 30, 60]; const THEME_MODES: ThemeMode[] = ["system", "light", "dark"]; @@ -303,3 +312,18 @@ export async function saveStartOnLogin(value: boolean): Promise { await store.set(START_ON_LOGIN_KEY, value); await store.save(); } + +export function isUIScale(value: unknown): value is UIScale { + return typeof value === "string" && UI_SCALE_VALUES.includes(value as UIScale); +} + +export async function loadUIScale(): Promise { + const stored = await store.get(UI_SCALE_KEY); + if (isUIScale(stored)) return stored; + return DEFAULT_UI_SCALE; +} + +export async function saveUIScale(value: UIScale): Promise { + await store.set(UI_SCALE_KEY, value); + await store.save(); +} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 1f7abada..5c684f30 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -26,12 +26,14 @@ import { MENUBAR_ICON_STYLE_OPTIONS, RESET_TIMER_DISPLAY_OPTIONS, THEME_OPTIONS, + UI_SCALE_OPTIONS, type AutoUpdateIntervalMinutes, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, type ResetTimerDisplayMode, type ThemeMode, + type UIScale, } from "@/lib/settings"; import type { TraySettingsPreview } from "@/hooks/app/use-tray-icon"; import { cn } from "@/lib/utils"; @@ -270,6 +272,8 @@ interface SettingsPageProps { onGlobalShortcutChange: (value: GlobalShortcut) => void; startOnLogin: boolean; onStartOnLoginChange: (value: boolean) => void; + uiScale: UIScale; + onUIScaleChange: (value: UIScale) => void; } export function SettingsPage({ @@ -291,6 +295,8 @@ export function SettingsPage({ onGlobalShortcutChange, startOnLogin, onStartOnLoginChange, + uiScale, + onUIScaleChange, }: SettingsPageProps) { const sensors = useSensors( useSensor(PointerSensor), @@ -484,6 +490,33 @@ export function SettingsPage({ Start on login +
+

UI Scale

+

+ Adjust text size and spacing +

+
+
+ {UI_SCALE_OPTIONS.map((option) => { + const isActive = option.value === uiScale; + return ( + + ); + })} +
+
+

Plugins

diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index 98ced539..d6a3ed72 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand" import { DEFAULT_AUTO_UPDATE_INTERVAL, + DEFAULT_UI_SCALE, DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_MENUBAR_ICON_STYLE, @@ -9,6 +10,7 @@ import { DEFAULT_THEME_MODE, type AutoUpdateIntervalMinutes, type DisplayMode, + type UIScale, type GlobalShortcut, type MenubarIconStyle, type ResetTimerDisplayMode, @@ -23,6 +25,7 @@ type AppPreferencesStore = { globalShortcut: GlobalShortcut startOnLogin: boolean menubarIconStyle: MenubarIconStyle + uiScale: UIScale setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void @@ -30,6 +33,7 @@ type AppPreferencesStore = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setUIScale: (value: UIScale) => void resetState: () => void } @@ -41,6 +45,7 @@ const initialState = { globalShortcut: DEFAULT_GLOBAL_SHORTCUT, startOnLogin: DEFAULT_START_ON_LOGIN, menubarIconStyle: DEFAULT_MENUBAR_ICON_STYLE, + uiScale: DEFAULT_UI_SCALE, } export const useAppPreferencesStore = create((set) => ({ @@ -52,5 +57,6 @@ export const useAppPreferencesStore = create((set) => ({ setGlobalShortcut: (value) => set({ globalShortcut: value }), setStartOnLogin: (value) => set({ startOnLogin: value }), setMenubarIconStyle: (value) => set({ menubarIconStyle: value }), + setUIScale: (value) => set({ uiScale: value }), resetState: () => set(initialState), }))