diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 21a7a494..dc6e0018 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 35d040c2..00c37aa5 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -807,6 +807,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 89d9347a..deb11810 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -809,6 +809,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 a5755c3b..fc34c1ae 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 b7a320b1..e51fb52e 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);') + }) })