Unify desktop zoom controls

PR #428 added a General Settings zoom slider after the desktop shortcut work already introduced a native-first app zoom controller. Keeping both paths would create double scaling and stale UI state, so the slider now routes through the existing controller and store state while the app shell no longer applies a second CSS zoom.

Constraint: UI zoom is device-local display state and should not be written into shared user settings.

Rejected: Keep cc-haha-ui-zoom plus AppShell style.zoom | it conflicts with shortcut zoom and multiplies visual scale.

Rejected: Persist zoom through /api/settings/user | it would sync display-specific state across machines.

Confidence: high

Scope-risk: moderate

Directive: Keep app zoom behind desktop/src/lib/appZoom.ts; do not add another storage key or DOM zoom application point without migration and shortcut sync tests.

Tested: cd desktop && bun run test -- --run src/lib/appZoom.test.ts src/hooks/useKeyboardShortcuts.test.tsx src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/lib/persistenceMigrations.test.ts src/lib/doctorRepair.test.ts

Tested: bun run check:desktop

Not-tested: Real Windows/Linux desktop runtime smoke.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 17:49:57 +08:00
parent 1503fd0007
commit 4b62d354d6
16 changed files with 187 additions and 63 deletions

View File

@ -144,6 +144,7 @@ describe('Settings > General tab', () => {
skipWebFetchPreflight: true, skipWebFetchPreflight: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
responseLanguage: '', responseLanguage: '',
uiZoom: 1,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
h5Access: { h5Access: {
enabled: false, enabled: false,
@ -167,6 +168,9 @@ describe('Settings > General tab', () => {
setResponseLanguage: vi.fn().mockImplementation(async (language: string) => { setResponseLanguage: vi.fn().mockImplementation(async (language: string) => {
useSettingsStore.setState({ responseLanguage: language }) useSettingsStore.setState({ responseLanguage: language })
}), }),
setUiZoom: vi.fn().mockImplementation((uiZoom: number) => {
useSettingsStore.setState({ uiZoom })
}),
setWebSearch: vi.fn().mockImplementation(async (webSearch) => { setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
useSettingsStore.setState({ webSearch }) useSettingsStore.setState({ webSearch })
}), }),
@ -251,6 +255,26 @@ describe('Settings > General tab', () => {
expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false') expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false')
}) })
it('updates the shared UI zoom setting from the General display control', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
await act(async () => {
fireEvent.change(screen.getByLabelText('UI Zoom'), {
target: { value: '1.25', valueAsNumber: 1.25 },
})
})
expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25)
expect(screen.getByText('125%')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset UI zoom to 100%' }))
})
expect(useSettingsStore.getState().setUiZoom).toHaveBeenLastCalledWith(1)
})
it('opens the Token usage tab from Settings navigation above Diagnostics', () => { it('opens the Token usage tab from Settings navigation above Diagnostics', () => {
render(<Settings />) render(<Settings />)

View File

@ -28,7 +28,6 @@ function isChatTab(tab: Tab | undefined) {
export function AppShell() { export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll) const fetchSettings = useSettingsStore((s) => s.fetchAll)
const uiZoom = useSettingsStore((s) => s.uiZoom)
const sidebarOpen = useUIStore((s) => s.sidebarOpen) const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar) const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen) const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
@ -195,7 +194,7 @@ export function AppShell() {
} }
return ( return (
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`} style={{ zoom: uiZoom }}> <div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
{isMobileShell && effectiveSidebarOpen ? ( {isMobileShell && effectiveSidebarOpen ? (
<button <button
type="button" type="button"

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { APP_ZOOM_STORAGE_KEY } from '../lib/appZoom' import { APP_ZOOM_STORAGE_KEY } from '../lib/appZoom'
import { useSettingsStore } from '../stores/settingsStore'
import { useKeyboardShortcuts } from './useKeyboardShortcuts' import { useKeyboardShortcuts } from './useKeyboardShortcuts'
function ShortcutHost() { function ShortcutHost() {
@ -22,6 +23,7 @@ describe('useKeyboardShortcuts app zoom', () => {
document.documentElement.removeAttribute('data-app-zoom-percent') document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom') document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom') document.body.style.removeProperty('zoom')
useSettingsStore.setState({ uiZoom: 1 })
setNavigatorPlatform('Win32') setNavigatorPlatform('Win32')
}) })
@ -41,6 +43,7 @@ describe('useKeyboardShortcuts app zoom', () => {
await waitFor(() => { await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1') expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1')
}) })
expect(useSettingsStore.getState().uiZoom).toBe(1.1)
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110') expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110')
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@ -52,6 +55,7 @@ describe('useKeyboardShortcuts app zoom', () => {
await waitFor(() => { await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1') expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1')
}) })
expect(useSettingsStore.getState().uiZoom).toBe(1)
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
code: 'NumpadAdd', code: 'NumpadAdd',

View File

@ -4,12 +4,10 @@ import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
import { import {
applyAppZoomLevel,
getAppZoomKeyboardAction, getAppZoomKeyboardAction,
initializeAppZoom,
nextAppZoomLevel, nextAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom' } from '../lib/appZoom'
import { useSettingsStore } from '../stores/settingsStore'
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const setActiveSession = useSessionStore((s) => s.setActiveSession) const setActiveSession = useSessionStore((s) => s.setActiveSession)
@ -20,6 +18,8 @@ export function useKeyboardShortcuts() {
const stopGeneration = useChatStore((s) => s.stopGeneration) const stopGeneration = useChatStore((s) => s.stopGeneration)
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle') const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
const uiZoom = useSettingsStore((s) => s.uiZoom)
const setUiZoom = useSettingsStore((s) => s.setUiZoom)
const activeModalRef = useRef(activeModal) const activeModalRef = useRef(activeModal)
activeModalRef.current = activeModal activeModalRef.current = activeModal
@ -27,18 +27,17 @@ export function useKeyboardShortcuts() {
chatStateRef.current = chatState chatStateRef.current = chatState
const activeTabIdRef = useRef(activeTabId) const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId activeTabIdRef.current = activeTabId
const appZoomLevelRef = useRef(readStoredAppZoomLevel()) const appZoomLevelRef = useRef(uiZoom)
appZoomLevelRef.current = uiZoom
useEffect(() => { useEffect(() => {
void initializeAppZoom()
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
const zoomAction = getAppZoomKeyboardAction(e) const zoomAction = getAppZoomKeyboardAction(e)
if (zoomAction) { if (zoomAction) {
e.preventDefault() e.preventDefault()
const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction) const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction)
appZoomLevelRef.current = nextZoom appZoomLevelRef.current = nextZoom
void applyAppZoomLevel(nextZoom) setUiZoom(nextZoom)
return return
} }
@ -80,5 +79,5 @@ export function useKeyboardShortcuts() {
document.addEventListener('keydown', handler) document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler)
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration]) }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration])
} }

View File

@ -768,7 +768,8 @@ export const en = {
'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.', 'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.',
'settings.general.webSearchSave': 'Save', 'settings.general.webSearchSave': 'Save',
'settings.general.uiZoom': 'UI Zoom', 'settings.general.uiZoom': 'UI Zoom',
'settings.general.uiZoomDescription': 'Scale the entire interface for better visibility on high-DPI displays.', 'settings.general.uiZoomDescription': 'Adjust the size of the entire interface.',
'settings.general.uiZoomReset': 'Reset UI zoom to 100%',
// ─── Empty Session ────────────────────────────────────── // ─── Empty Session ──────────────────────────────────────
'empty.title': 'New session', 'empty.title': 'New session',

View File

@ -770,7 +770,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch失败或非 Claude 模型时再使用 Tavily/Brave。', 'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch失败或非 Claude 模型时再使用 Tavily/Brave。',
'settings.general.webSearchSave': '保存', 'settings.general.webSearchSave': '保存',
'settings.general.uiZoom': '界面缩放', 'settings.general.uiZoom': '界面缩放',
'settings.general.uiZoomDescription': '缩放整个界面,以便在高 DPI 屏幕上获得更好的可见性。', 'settings.general.uiZoomDescription': '调整整个界面的显示大小。',
'settings.general.uiZoomReset': '重置界面缩放到 100%',
// ─── Empty Session ────────────────────────────────────── // ─── Empty Session ──────────────────────────────────────
'empty.title': '新建会话', 'empty.title': '新建会话',

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { import {
APP_ZOOM_STORAGE_KEY, APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
applyAppZoomLevel, applyAppZoomLevel,
getAppZoomKeyboardAction, getAppZoomKeyboardAction,
initializeAppZoom, initializeAppZoom,
@ -39,6 +40,15 @@ describe('appZoom', () => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2') expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2')
}) })
it('reads the legacy UI zoom key when the app zoom key is absent', async () => {
window.localStorage.setItem(LEGACY_UI_ZOOM_STORAGE_KEY, '1.25')
await initializeAppZoom()
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull()
})
it('persists app zoom changes', async () => { it('persists app zoom changes', async () => {
await applyAppZoomLevel(1.3) await applyAppZoomLevel(1.3)

View File

@ -1,8 +1,10 @@
export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom' export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom'
export const LEGACY_UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom'
export const DEFAULT_APP_ZOOM = 1 export const DEFAULT_APP_ZOOM = 1
export const MIN_APP_ZOOM = 0.5 export const MIN_APP_ZOOM = 0.5
export const MAX_APP_ZOOM = 2 export const MAX_APP_ZOOM = 2
export const APP_ZOOM_STEP = 0.1 export const APP_ZOOM_STEP = 0.1
export const APP_ZOOM_CONTROL_STEP = 0.05
export type AppZoomAction = 'in' | 'out' | 'reset' export type AppZoomAction = 'in' | 'out' | 'reset'
@ -45,7 +47,9 @@ export function isValidStoredAppZoomLevel(value: string | null): boolean {
export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number { export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number {
if (!storage) return DEFAULT_APP_ZOOM if (!storage) return DEFAULT_APP_ZOOM
try { try {
return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY)) const stored = storage.getItem(APP_ZOOM_STORAGE_KEY)
if (stored !== null) return normalizeAppZoomLevel(stored)
return normalizeAppZoomLevel(storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY))
} catch { } catch {
return DEFAULT_APP_ZOOM return DEFAULT_APP_ZOOM
} }

View File

@ -1,5 +1,5 @@
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor' import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
import { APP_ZOOM_STORAGE_KEY } from './appZoom' import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom'
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations' import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
export const SAFE_DOCTOR_STORAGE_KEYS = [ export const SAFE_DOCTOR_STORAGE_KEYS = [
@ -8,6 +8,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [
'cc-haha-theme', 'cc-haha-theme',
'cc-haha-locale', 'cc-haha-locale',
APP_ZOOM_STORAGE_KEY, APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
DESKTOP_PERSISTENCE_VERSION_KEY, DESKTOP_PERSISTENCE_VERSION_KEY,
] as const ] as const

View File

@ -81,6 +81,19 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull() expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull()
}) })
test('migrates the legacy UI zoom key into app zoom storage', () => {
window.localStorage.setItem('cc-haha-ui-zoom', '1.25')
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).toEqual(expect.arrayContaining([
'cc-haha-app-zoom',
'cc-haha-ui-zoom',
]))
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
expect(window.localStorage.getItem('cc-haha-ui-zoom')).toBeNull()
})
test('does not throw if schema version persistence is blocked', () => { test('does not throw if schema version persistence is blocked', () => {
const storage = { const storage = {
getItem: window.localStorage.getItem.bind(window.localStorage), getItem: window.localStorage.getItem.bind(window.localStorage),

View File

@ -1,5 +1,10 @@
import { THEME_MODES } from '../types/settings' import { THEME_MODES } from '../types/settings'
import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom' import {
APP_ZOOM_STORAGE_KEY,
LEGACY_UI_ZOOM_STORAGE_KEY,
isValidStoredAppZoomLevel,
normalizeAppZoomLevel,
} from './appZoom'
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1 export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion' export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
@ -121,6 +126,17 @@ function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationRepor
storage.removeItem(APP_ZOOM_STORAGE_KEY) storage.removeItem(APP_ZOOM_STORAGE_KEY)
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY) report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
} }
const currentValue = storage.getItem(APP_ZOOM_STORAGE_KEY)
const legacyValue = storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY)
if (currentValue === null && legacyValue !== null && isValidStoredAppZoomLevel(legacyValue)) {
storage.setItem(APP_ZOOM_STORAGE_KEY, String(normalizeAppZoomLevel(legacyValue)))
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
}
if (legacyValue !== null) {
storage.removeItem(LEGACY_UI_ZOOM_STORAGE_KEY)
report.migratedKeys.push(LEGACY_UI_ZOOM_STORAGE_KEY)
}
} }
function runMigrationStep( function runMigrationStep(

View File

@ -1,9 +1,11 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import './theme/globals.css' import './theme/globals.css'
import { initializeAppZoom } from './lib/appZoom'
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations' import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
runDesktopPersistenceMigrations() runDesktopPersistenceMigrations()
void initializeAppZoom()
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([ const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
import('./App'), import('./App'),
import('./components/ErrorBoundary'), import('./components/ErrorBoundary'),

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react' import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react' import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
import { useSettingsStore, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore' import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore' import { useProviderStore } from '../stores/providerStore'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal' import { Modal } from '../components/shared/Modal'
@ -1532,6 +1532,45 @@ function GeneralSettings() {
))} ))}
</div> </div>
{/* UI Zoom */}
<div className="mb-8">
<div className="mb-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)]">{t('settings.general.uiZoomDescription')}</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<span className="min-w-[48px] rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 text-center text-sm font-medium text-[var(--color-text-secondary)]">
{Math.round(uiZoom * 100)}%
</span>
<button
type="button"
aria-label={t('settings.general.uiZoomReset')}
title={t('settings.general.uiZoomReset')}
onClick={() => setUiZoom(UI_ZOOM_DEFAULT)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
100%
</button>
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-9 text-right text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
<input
type="range"
aria-label={t('settings.general.uiZoom')}
min={UI_ZOOM_MIN}
max={UI_ZOOM_MAX}
step={UI_ZOOM_STEP}
value={uiZoom}
onChange={(e) => setUiZoom(e.currentTarget.valueAsNumber)}
className="flex-1"
/>
<span className="w-9 text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
</div>
</div>
{/* Language selector */} {/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2> <h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p> <p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
@ -1777,33 +1816,6 @@ function GeneralSettings() {
</div> </div>
</div> </div>
</div> </div>
{/* UI Zoom */}
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.uiZoom')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.uiZoomDescription')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-sm text-[var(--color-text-secondary)]">
{Math.round(uiZoom * 100)}%
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
<input
type="range"
min={UI_ZOOM_MIN}
max={UI_ZOOM_MAX}
step={UI_ZOOM_STEP}
value={uiZoom}
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
onMouseUp={(e) => e.currentTarget.blur()}
className="flex-1"
/>
<span className="text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@ -22,6 +22,47 @@ describe('settingsStore locale defaults', () => {
}) })
}) })
describe('settingsStore UI zoom', () => {
beforeEach(() => {
vi.resetModules()
window.localStorage.clear()
document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom')
})
it('hydrates from the app zoom storage key', async () => {
window.localStorage.setItem('cc-haha-app-zoom', '1.25')
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
})
it('applies and persists UI zoom changes through the shared app zoom controller', async () => {
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.getState().setUiZoom(1.25)
await vi.waitFor(() => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
})
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
})
it('clamps UI zoom changes to the supported range', async () => {
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.getState().setUiZoom(9)
await vi.waitFor(() => {
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('2')
})
expect(useSettingsStore.getState().uiZoom).toBe(2)
})
})
describe('settingsStore desktop notification persistence', () => { describe('settingsStore desktop notification persistence', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()

View File

@ -5,13 +5,22 @@ import { modelsApi } from '../api/models'
import { h5AccessApi } from '../api/h5Access' import { h5AccessApi } from '../api/h5Access'
import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings' import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings'
import type { Locale } from '../i18n' import type { Locale } from '../i18n'
import {
APP_ZOOM_CONTROL_STEP,
DEFAULT_APP_ZOOM,
MAX_APP_ZOOM,
MIN_APP_ZOOM,
applyAppZoomLevel,
normalizeAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom'
import { useUIStore } from './uiStore' import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale' const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom' export const UI_ZOOM_MIN = MIN_APP_ZOOM
export const UI_ZOOM_MIN = 0.5 export const UI_ZOOM_MAX = MAX_APP_ZOOM
export const UI_ZOOM_MAX = 2.0 export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_STEP = 0.05 export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve() let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()
function getStoredLocale(): Locale { function getStoredLocale(): Locale {
@ -22,17 +31,6 @@ function getStoredLocale(): Locale {
return 'zh' return 'zh'
} }
function getStoredUiZoom(): number {
try {
const stored = localStorage.getItem(UI_ZOOM_STORAGE_KEY)
if (stored === null) return 1.0
const parsed = parseFloat(stored)
if (isNaN(parsed)) return 1.0
return Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, parsed))
} catch { /* localStorage unavailable */ }
return 1.0
}
type SettingsStore = { type SettingsStore = {
permissionMode: PermissionMode permissionMode: PermissionMode
currentModel: ModelInfo | null currentModel: ModelInfo | null
@ -96,14 +94,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5Access: DEFAULT_H5_ACCESS_SETTINGS,
h5AccessError: null, h5AccessError: null,
responseLanguage: '', responseLanguage: '',
uiZoom: getStoredUiZoom(), uiZoom: readStoredAppZoomLevel(),
isLoading: false, isLoading: false,
error: null, error: null,
setUiZoom: (zoom: number) => { setUiZoom: (zoom: number) => {
const clamped = Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, zoom)) const level = normalizeAppZoomLevel(zoom)
set({ uiZoom: clamped }) set({ uiZoom: level })
try { localStorage.setItem(UI_ZOOM_STORAGE_KEY, String(clamped)) } catch { /* noop */ } void applyAppZoomLevel(level)
}, },
fetchAll: async () => { fetchAll: async () => {

View File

@ -43,6 +43,5 @@ export type UserSettings = {
desktopNotificationsEnabled?: boolean desktopNotificationsEnabled?: boolean
webSearch?: WebSearchSettings webSearch?: WebSearchSettings
language?: string language?: string
uiZoom?: number
[key: string]: unknown [key: string]: unknown
} }