From 1f0c888a13ac430080518e0f0e8bd855c621eb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 14 May 2026 23:25:20 +0800 Subject: [PATCH] Stabilize desktop zoom settings interaction The settings zoom slider is a secondary desktop preference, so it now previews drag movement locally before committing the app zoom on release and sits below system notifications. The shortcut hint also spells out reset behavior, and the dark theme slider thumb uses stronger contrast tokens so it stays visible while dragging. Constraint: Desktop zoom changes affect the full application surface and should not update continuously during pointer drag. Rejected: Keep zoom directly under theme selection | the control is low-frequency and was visually dominating General settings. Confidence: high Scope-risk: narrow Tested: bunx vitest run src/__tests__/generalSettings.test.tsx src/theme/globals.test.ts Tested: browser smoke verified dark-theme order and slider visibility at localhost:5174 Not-tested: Full bun run verify --- .../src/__tests__/generalSettings.test.tsx | 36 +++- desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/pages/Settings.tsx | 154 ++++++++++---- desktop/src/theme/globals.css | 188 ++++++++++++++++++ desktop/src/theme/globals.test.ts | 7 + 6 files changed, 350 insertions(+), 43 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index cf9cecfe..76265ba0 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -255,18 +255,48 @@ describe('Settings > General tab', () => { expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false') }) - it('updates the shared UI zoom setting from the General display control', async () => { + it('keeps UI zoom below system notifications because it is a secondary setting', () => { render() fireEvent.click(screen.getByText('General')) + + const notificationsHeading = screen.getByRole('heading', { name: 'System Notifications' }) + const uiZoomHeading = screen.getByRole('heading', { name: 'UI Zoom' }) + const webFetchHeading = screen.getByRole('heading', { name: 'WebFetch Preflight' }) + + expect((notificationsHeading.compareDocumentPosition(uiZoomHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + expect((uiZoomHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + }) + + it('previews UI zoom while dragging and applies it only when the user releases the slider', async () => { + render() + + fireEvent.click(screen.getByText('General')) + expect(screen.getByText('Shortcuts are faster:')).toBeInTheDocument() + expect(screen.getByText('macOS')).toBeInTheDocument() + expect(screen.getByText('Windows / Linux')).toBeInTheDocument() + expect(screen.getByText('0 resets zoom to 100%.')).toBeInTheDocument() + + const slider = screen.getByLabelText('UI Zoom') + + fireEvent.pointerDown(slider, { pointerId: 1 }) await act(async () => { - fireEvent.change(screen.getByLabelText('UI Zoom'), { + fireEvent.change(slider, { target: { value: '1.25', valueAsNumber: 1.25 }, }) }) + expect(screen.getAllByText('125%')).toHaveLength(2) + expect(useSettingsStore.getState().setUiZoom).not.toHaveBeenCalledWith(1.25) + expect(slider).toHaveClass('settings-zoom-range') + expect(slider.closest('.settings-zoom-control')).toHaveClass('is-dragging') + expect(slider.closest('.settings-zoom-control')).toHaveStyle({ '--settings-zoom-range-progress': '50%' }) + + await act(async () => { + fireEvent.pointerUp(slider, { pointerId: 1 }) + }) + 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%' })) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 66c8f129..eb0893fb 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -803,6 +803,10 @@ export const en = { 'settings.general.webSearchSave': 'Save', 'settings.general.uiZoom': 'UI Zoom', 'settings.general.uiZoomDescription': 'Adjust the size of the entire interface.', + 'settings.general.uiZoomShortcutHint': 'Shortcuts are faster:', + 'settings.general.uiZoomShortcutMac': 'macOS', + 'settings.general.uiZoomShortcutWindows': 'Windows / Linux', + 'settings.general.uiZoomShortcutResetHint': '0 resets zoom to 100%.', 'settings.general.uiZoomReset': 'Reset UI zoom to 100%', // ─── Empty Session ────────────────────────────────────── diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 704e5bc3..dfb7010e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -805,6 +805,10 @@ export const zh: Record = { 'settings.general.webSearchSave': '保存', 'settings.general.uiZoom': '界面缩放', 'settings.general.uiZoomDescription': '调整整个界面的显示大小。', + 'settings.general.uiZoomShortcutHint': '快捷键更直接:', + 'settings.general.uiZoomShortcutMac': 'macOS', + 'settings.general.uiZoomShortcutWindows': 'Windows / Linux', + 'settings.general.uiZoomShortcutResetHint': '0 表示恢复到 100%。', 'settings.general.uiZoomReset': '重置界面缩放到 100%', // ─── Empty Session ────────────────────────────────────── diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 01eb6e26..5066a517 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react' +import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react' import QRCode from 'qrcode' import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react' import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore' @@ -1392,12 +1392,22 @@ function GeneralSettings() { const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) + const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom) + const [isUiZoomDragging, setIsUiZoomDragging] = useState(false) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) + const uiZoomPercent = Math.round(uiZoomDraft * 100) + const uiZoomRangeProgress = `${((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 100}%` useEffect(() => { setWebSearchDraft(webSearch) }, [webSearch]) + useEffect(() => { + if (!isUiZoomDragging) { + setUiZoomDraft(uiZoom) + } + }, [isUiZoomDragging, uiZoom]) + useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1513,6 +1523,107 @@ function GeneralSettings() { } } + const commitUiZoom = (value: number) => { + const nextZoom = Number.isFinite(value) ? value : UI_ZOOM_DEFAULT + setIsUiZoomDragging(false) + setUiZoomDraft(nextZoom) + setUiZoom(nextZoom) + } + + const uiZoomSection = ( +
+
+
+

{t('settings.general.uiZoom')}

+

{t('settings.general.uiZoomDescription')}

+
+ {t('settings.general.uiZoomShortcutHint')} + + {t('settings.general.uiZoomShortcutMac')} + + + + / + + - + / + + 0 + + + {t('settings.general.uiZoomShortcutWindows')} + Ctrl + + + / + Ctrl + - + / + Ctrl + 0 + + {t('settings.general.uiZoomShortcutResetHint')} +
+
+
+ + {uiZoomPercent}% + + +
+
+
+ {Math.round(UI_ZOOM_MIN * 100)}% +
+ + { + setIsUiZoomDragging(true) + e.currentTarget.setPointerCapture?.(e.pointerId) + }} + onPointerUp={(e) => commitUiZoom(e.currentTarget.valueAsNumber)} + onPointerCancel={(e) => commitUiZoom(e.currentTarget.valueAsNumber)} + onChange={(e) => setUiZoomDraft(e.currentTarget.valueAsNumber)} + onKeyUp={(e) => { + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)) { + commitUiZoom(e.currentTarget.valueAsNumber) + } + }} + onBlur={(e) => { + if (uiZoomDraft !== uiZoom) { + commitUiZoom(e.currentTarget.valueAsNumber) + } + }} + className="settings-zoom-range w-full" + /> +
+ {Math.round(UI_ZOOM_MAX * 100)}% +
+
+ ) + return (
{/* Appearance selector */} @@ -1535,45 +1646,6 @@ function GeneralSettings() { ))}
- {/* UI Zoom */} -
-
-
-

{t('settings.general.uiZoom')}

-

{t('settings.general.uiZoomDescription')}

-
-
- - {Math.round(uiZoom * 100)}% - - -
-
-
- {Math.round(UI_ZOOM_MIN * 100)}% - setUiZoom(e.currentTarget.valueAsNumber)} - className="flex-1" - /> - {Math.round(UI_ZOOM_MAX * 100)}% -
-
- {/* Language selector */}

{t('settings.general.languageTitle')}

{t('settings.general.languageDescription')}

@@ -1704,6 +1776,8 @@ function GeneralSettings() { + {uiZoomSection} +

{t('settings.general.webFetchPreflightTitle')}

{t('settings.general.webFetchPreflightDescription')}

diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 196dcbc8..6699714c 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -108,6 +108,194 @@ outline: none; } +.settings-zoom-kbd { + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 1px solid var(--color-border); + border-radius: 5px; + background: var(--color-surface-container-lowest); + color: var(--color-text-secondary); + font-family: var(--font-label); + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; + box-shadow: inset 0 -1px 0 rgba(27, 28, 26, 0.08); +} + +.settings-zoom-control { + --settings-zoom-range-progress: 33.333%; + --settings-zoom-preview-scale: 0.88; + --settings-zoom-preview-opacity: 0; + --settings-zoom-track-empty: var(--color-surface-container-high); + --settings-zoom-thumb-bg: var(--color-surface-container-lowest); + --settings-zoom-thumb-border: var(--color-border); + --settings-zoom-thumb-shadow: 0 2px 8px rgba(27, 28, 26, 0.16); + padding-top: 22px; +} + +[data-theme="dark"] .settings-zoom-control { + --settings-zoom-track-empty: var(--color-surface-container-highest); + --settings-zoom-thumb-bg: var(--color-surface-bright); + --settings-zoom-thumb-border: rgba(255, 181, 159, 0.78); + --settings-zoom-thumb-shadow: 0 0 0 3px rgba(255, 181, 159, 0.16), 0 10px 24px rgba(0, 0, 0, 0.5); +} + +.settings-zoom-control.is-dragging { + --settings-zoom-preview-scale: 1; + --settings-zoom-preview-opacity: 1; +} + +.settings-zoom-range-wrap { + position: relative; +} + +.settings-zoom-preview { + position: absolute; + left: clamp(24px, var(--settings-zoom-range-progress), calc(100% - 24px)); + top: -30px; + z-index: 1; + min-width: 52px; + transform: translateX(-50%) scale(var(--settings-zoom-preview-scale)); + transform-origin: 50% 100%; + border: 1px solid var(--color-surface-glass-border); + border-radius: 999px; + background: var(--color-surface-glass); + padding: 4px 9px; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + opacity: var(--settings-zoom-preview-opacity); + box-shadow: var(--shadow-dropdown); + backdrop-filter: blur(14px) saturate(1.15); + -webkit-backdrop-filter: blur(14px) saturate(1.15); + pointer-events: none; + transition: + left 260ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 160ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.settings-zoom-preview::after { + content: ""; + position: absolute; + left: 50%; + bottom: -4px; + width: 8px; + height: 8px; + transform: translateX(-50%) rotate(45deg); + border-right: 1px solid var(--color-surface-glass-border); + border-bottom: 1px solid var(--color-surface-glass-border); + background: var(--color-surface-glass); +} + +.settings-zoom-range { + appearance: none; + display: block; + height: 20px; + cursor: pointer; + background: transparent; + accent-color: var(--color-brand); +} + +.settings-zoom-range:focus { + outline: none; +} + +.settings-zoom-range:focus-visible { + outline: none; +} + +.settings-zoom-range::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + border: 1px solid var(--color-border); + background: + linear-gradient( + 90deg, + var(--color-brand) 0%, + var(--color-brand) var(--settings-zoom-range-progress), + var(--settings-zoom-track-empty) var(--settings-zoom-range-progress), + var(--settings-zoom-track-empty) 100% + ); + transition: background 220ms ease, border-color 180ms ease; +} + +.settings-zoom-range::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + margin-top: -7px; + border-radius: 999px; + border: 1px solid var(--settings-zoom-thumb-border); + background: var(--settings-zoom-thumb-bg); + box-shadow: var(--settings-zoom-thumb-shadow); + transition: border-color 180ms ease, box-shadow 220ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.settings-zoom-range:hover::-webkit-slider-thumb { + border-color: var(--color-border-focus); +} + +.settings-zoom-control.is-dragging .settings-zoom-range::-webkit-slider-thumb { + transform: scale(1.18); + border-color: var(--color-brand); + box-shadow: var(--shadow-dropdown); +} + +.settings-zoom-range:focus-visible::-webkit-slider-thumb { + border-color: var(--color-brand); + box-shadow: var(--shadow-focus-ring), 0 2px 8px rgba(27, 28, 26, 0.16); +} + +.settings-zoom-range::-moz-range-track { + height: 6px; + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--settings-zoom-track-empty); + transition: background 220ms ease, border-color 180ms ease; +} + +.settings-zoom-range::-moz-range-progress { + height: 6px; + border-radius: 999px; + background: var(--color-brand); +} + +.settings-zoom-range::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid var(--settings-zoom-thumb-border); + background: var(--settings-zoom-thumb-bg); + box-shadow: var(--settings-zoom-thumb-shadow); + transition: border-color 180ms ease, box-shadow 220ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.settings-zoom-control.is-dragging .settings-zoom-range::-moz-range-thumb { + transform: scale(1.18); + border-color: var(--color-brand); + box-shadow: var(--shadow-dropdown); +} + +.settings-zoom-range:focus-visible::-moz-range-thumb { + border-color: var(--color-brand); + box-shadow: var(--shadow-focus-ring), 0 2px 8px rgba(27, 28, 26, 0.16); +} + +@media (prefers-reduced-motion: reduce) { + .settings-zoom-preview, + .settings-zoom-range::-webkit-slider-runnable-track, + .settings-zoom-range::-webkit-slider-thumb, + .settings-zoom-range::-moz-range-track, + .settings-zoom-range::-moz-range-thumb { + transition-duration: 0.01ms !important; + } +} + /* ─── Tailwind Theme Override (matches prototype design system) ─── */ @theme { --color-primary: #8F482F; diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index c82c8c4a..ed212295 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -66,4 +66,11 @@ describe('desktop theme tokens', () => { it('avoids color-mix in startup-critical shell chrome for Safari 15 WebView support', () => { expect(css).not.toContain('color-mix(') }) + + it('keeps the UI zoom slider thumb visible in dark mode', () => { + expect(css).toContain('[data-theme="dark"] .settings-zoom-control') + expect(css).toContain('--settings-zoom-thumb-bg: var(--color-surface-bright);') + expect(css).toContain('--settings-zoom-thumb-border: rgba(255, 181, 159, 0.78);') + expect(css).toContain('box-shadow: var(--settings-zoom-thumb-shadow);') + }) })