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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 23:25:20 +08:00
parent e070c4a0d0
commit 1f0c888a13
6 changed files with 350 additions and 43 deletions

View File

@ -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(<Settings />)
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(<Settings />)
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%' }))

View File

@ -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 ──────────────────────────────────────

View File

@ -805,6 +805,10 @@ export const zh: Record<TranslationKey, string> = {
'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 ──────────────────────────────────────

View File

@ -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<DesktopNotificationPermission>('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 = (
<div className="mt-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 className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('settings.general.uiZoomShortcutHint')}</span>
<span className="inline-flex items-center gap-1">
<span className="font-medium text-[var(--color-text-secondary)]">{t('settings.general.uiZoomShortcutMac')}</span>
<kbd className="settings-zoom-kbd"></kbd>
<kbd className="settings-zoom-kbd">+</kbd>
<span>/</span>
<kbd className="settings-zoom-kbd"></kbd>
<kbd className="settings-zoom-kbd">-</kbd>
<span>/</span>
<kbd className="settings-zoom-kbd"></kbd>
<kbd className="settings-zoom-kbd">0</kbd>
</span>
<span className="inline-flex items-center gap-1">
<span className="font-medium text-[var(--color-text-secondary)]">{t('settings.general.uiZoomShortcutWindows')}</span>
<kbd className="settings-zoom-kbd">Ctrl</kbd>
<kbd className="settings-zoom-kbd">+</kbd>
<span>/</span>
<kbd className="settings-zoom-kbd">Ctrl</kbd>
<kbd className="settings-zoom-kbd">-</kbd>
<span>/</span>
<kbd className="settings-zoom-kbd">Ctrl</kbd>
<kbd className="settings-zoom-kbd">0</kbd>
</span>
<span>{t('settings.general.uiZoomShortcutResetHint')}</span>
</div>
</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)]">
{uiZoomPercent}%
</span>
<button
type="button"
aria-label={t('settings.general.uiZoomReset')}
title={t('settings.general.uiZoomReset')}
onClick={() => {
setIsUiZoomDragging(false)
setUiZoomDraft(UI_ZOOM_DEFAULT)
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={`settings-zoom-control flex items-center gap-3 ${isUiZoomDragging ? 'is-dragging' : ''}`}
style={{ '--settings-zoom-range-progress': uiZoomRangeProgress } as CSSProperties}
>
<span className="w-9 text-right text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MIN * 100)}%</span>
<div className="settings-zoom-range-wrap flex-1">
<div className="settings-zoom-preview" aria-hidden="true">
{uiZoomPercent}%
</div>
<input
type="range"
aria-label={t('settings.general.uiZoom')}
min={UI_ZOOM_MIN}
max={UI_ZOOM_MAX}
step={UI_ZOOM_STEP}
value={uiZoomDraft}
onPointerDown={(e) => {
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"
/>
</div>
<span className="w-9 text-xs text-[var(--color-text-tertiary)]">{Math.round(UI_ZOOM_MAX * 100)}%</span>
</div>
</div>
)
return (
<div className="max-w-xl">
{/* Appearance selector */}
@ -1535,45 +1646,6 @@ function GeneralSettings() {
))}
</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 */}
<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>
@ -1704,6 +1776,8 @@ function GeneralSettings() {
</div>
</div>
{uiZoomSection}
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>

View File

@ -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;

View File

@ -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);')
})
})