Merge desktop zoom settings polish into main

Bring the verified settings zoom interaction cleanup from the desktop worktree onto local main while preserving the existing main history.

Constraint: Local main already contains unrelated desktop commits after the worktree base.
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 after merge
This commit is contained in:
程序员阿江(Relakkes) 2026-05-14 23:25:43 +08:00
commit 57e66f0019
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

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

View File

@ -809,6 +809,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);')
})
})