From c279643cce9aecfca0add0759df2c3aae32b8348 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: Fri, 15 May 2026 19:36:14 +0800 Subject: [PATCH] Prevent Settings zoom controls from drifting Settings and keyboard zoom controls were able to diverge because the slider only committed on release and captured pointer events around the native range control. The slider now commits through the shared zoom store as it changes, while the range keeps its native drag behavior. The full desktop gate also enforces Safari 15 compatibility for startup CSS, so the remaining memory theme color-mix tokens were replaced with static equivalents. Constraint: macOS 12 Safari 15 WebView cannot rely on CSS color-mix in startup-critical desktop CSS Rejected: Commit Settings zoom only on pointer release | leaves shortcut state and slider state temporarily out of sync Confidence: high Scope-risk: narrow Directive: Keep Settings slider, reset, and keyboard shortcuts routed through the shared uiZoom/appZoom state Tested: bun run check:desktop Tested: agent-browser Settings zoom smoke against local Vite plus server Not-tested: Packaged Tauri app manual drag on macOS hardware --- .../src/__tests__/generalSettings.test.tsx | 23 ++++++++++++++++-- desktop/src/pages/Settings.tsx | 24 +++++++++---------- desktop/src/theme/globals.css | 19 ++++++++------- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index dc6e0018..3e36eafb 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -268,7 +268,7 @@ describe('Settings > General tab', () => { 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 () => { + it('applies UI zoom while dragging so the slider and app zoom stay synchronized', async () => { render() fireEvent.click(screen.getByText('General')) @@ -287,7 +287,9 @@ describe('Settings > General tab', () => { }) expect(screen.getAllByText('125%')).toHaveLength(2) - expect(useSettingsStore.getState().setUiZoom).not.toHaveBeenCalledWith(1.25) + expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25) + expect(useSettingsStore.getState().uiZoom).toBe(1.25) + expect(slider).toHaveValue('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%' }) @@ -297,6 +299,7 @@ describe('Settings > General tab', () => { }) expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25) + expect(slider.closest('.settings-zoom-control')).not.toHaveClass('is-dragging') await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Reset UI zoom to 100%' })) @@ -305,6 +308,22 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setUiZoom).toHaveBeenLastCalledWith(1) }) + it('updates the UI zoom slider when shortcut zoom changes the shared setting while Settings is open', async () => { + render() + + fireEvent.click(screen.getByText('General')) + + const slider = screen.getByLabelText('UI Zoom') + + await act(async () => { + useSettingsStore.setState({ uiZoom: 1.1 }) + }) + + expect(slider).toHaveValue('1.1') + expect(screen.getAllByText('110%')).toHaveLength(2) + expect(slider.closest('.settings-zoom-control')).toHaveStyle({ '--settings-zoom-range-progress': '40%' }) + }) + it('opens the Token usage tab from Settings navigation above Diagnostics', () => { render() diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index fc34c1ae..01d07690 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1396,7 +1396,7 @@ function GeneralSettings() { 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}%` + const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%` useEffect(() => { setWebSearchDraft(webSearch) @@ -1523,9 +1523,11 @@ function GeneralSettings() { } } - const commitUiZoom = (value: number) => { + const commitUiZoom = (value: number, options: { keepDragging?: boolean } = {}) => { const nextZoom = Number.isFinite(value) ? value : UI_ZOOM_DEFAULT - setIsUiZoomDragging(false) + if (!options.keepDragging) { + setIsUiZoomDragging(false) + } setUiZoomDraft(nextZoom) setUiZoom(nextZoom) } @@ -1599,21 +1601,17 @@ function GeneralSettings() { max={UI_ZOOM_MAX} step={UI_ZOOM_STEP} value={uiZoomDraft} - onPointerDown={(e) => { + onPointerDown={() => { 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) - } }} + onPointerUp={() => setIsUiZoomDragging(false)} + onPointerCancel={() => setIsUiZoomDragging(false)} + onChange={(e) => commitUiZoom(e.currentTarget.valueAsNumber, { keepDragging: isUiZoomDragging })} onBlur={(e) => { if (uiZoomDraft !== uiZoom) { commitUiZoom(e.currentTarget.valueAsNumber) + } else { + setIsUiZoomDragging(false) } }} className="settings-zoom-range w-full" diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index e51fb52e..1f787e85 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -199,6 +199,7 @@ cursor: pointer; background: transparent; accent-color: var(--color-brand); + touch-action: none; } .settings-zoom-range:focus { @@ -366,9 +367,9 @@ --color-surface-hover: var(--color-surface-container-high); --color-surface-selected: var(--color-surface-container); --color-memory-accent: #3B7068; - --color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 7%, var(--color-surface-container-lowest)); - --color-memory-border: color-mix(in srgb, var(--color-memory-accent) 26%, var(--color-border)); - --color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 10%, var(--color-surface-container-lowest)); + --color-memory-surface: #F1F5F4; + --color-memory-border: #B1ACA5; + --color-memory-icon-bg: #EBF1F0; --color-model-option-selected-bg: var(--color-primary-fixed); --color-model-option-selected-border: rgba(143, 72, 47, 0.2); --color-activity-heat-0: var(--color-surface-container); @@ -567,9 +568,9 @@ --color-surface-hover: #F2F5F8; --color-surface-selected: #E9EEF3; --color-memory-accent: #0F766E; - --color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 5%, #FFFFFF); - --color-memory-border: color-mix(in srgb, var(--color-memory-accent) 24%, var(--color-border)); - --color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 8%, #FFFFFF); + --color-memory-surface: #F3F8F8; + --color-memory-border: #ACC9CC; + --color-memory-icon-bg: #ECF4F3; --color-model-option-selected-bg: #FFF0EA; --color-model-option-selected-border: rgba(143, 72, 47, 0.2); --color-activity-heat-0: #EEF2F6; @@ -745,9 +746,9 @@ --color-surface-hover: var(--color-surface-container-highest); --color-surface-selected: var(--color-surface-container); --color-memory-accent: #7DD3C7; - --color-memory-surface: color-mix(in srgb, var(--color-memory-accent) 9%, var(--color-surface-container-lowest)); - --color-memory-border: color-mix(in srgb, var(--color-memory-accent) 26%, var(--color-border)); - --color-memory-icon-bg: color-mix(in srgb, var(--color-memory-accent) 12%, var(--color-surface-container-lowest)); + --color-memory-surface: #18201F; + --color-memory-border: rgba(113, 162, 151, 0.39); + --color-memory-icon-bg: #1B2624; --color-model-option-selected-bg: rgba(255, 181, 159, 0.13); --color-model-option-selected-border: rgba(255, 181, 159, 0.34); --color-activity-heat-0: #242322;