diff --git a/shared/settings.ts b/shared/settings.ts index 451613a68..c20f3a1aa 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -33,7 +33,14 @@ export const CLAUDE_PERMISSION_MODE_VALUES = ['default', 'plan', 'acceptEdits', const EXTERNAL_EDITOR_VALUES = ['auto', 'cursor', 'code', 'custom'] as const const NETWORK_HOST_VALUES = ['127.0.0.1', '0.0.0.0'] as const const UI_SCALE_MIN = 0.75 -const UI_SCALE_MAX = 1.5 +const UI_SCALE_MAX = 4 +// Slider stops for the UI scale control, in integer percent (avoids float drift). +// Fine 5% steps up to 200%, coarse 25% steps to 400%. +export const UI_SCALE_PERCENT_OPTIONS: readonly number[] = [ + 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145, 150, + 155, 160, 165, 170, 175, 180, 185, 190, 195, 200, + 225, 250, 275, 300, 325, 350, 375, 400, +] const TERMINAL_FONT_SIZE_MIN = 12 const TERMINAL_FONT_SIZE_MAX = 32 const TERMINAL_LINE_HEIGHT_MIN = 1 diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index 3f6e308af..4c0a58a11 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -5,12 +5,14 @@ import { terminalThemes, darkThemes, lightThemes, getTerminalTheme } from '@/lib import { resolveTerminalFontFamily } from '@/lib/terminal-fonts' import type { AppSettings, TerminalTheme } from '@/store/types' import type { SettingsSectionProps } from './settings-types' +import { UI_SCALE_PERCENT_OPTIONS } from '@shared/settings' import { SettingsSection, SettingsRow, SegmentedControl, Toggle, RangeSlider, + SteppedRangeInput, } from './settings-controls' type PreviewTokenKind = @@ -307,15 +309,13 @@ export default function AppearanceSettings({ - `${Math.round(v * 100)}%`} - onChange={(v) => { - applyLocalSetting({ uiScale: v }) + { + applyLocalSetting({ uiScale: pct / 100 }) }} /> diff --git a/src/components/settings/settings-controls.tsx b/src/components/settings/settings-controls.tsx index 8e15ddb6f..ebe779381 100644 --- a/src/components/settings/settings-controls.tsx +++ b/src/components/settings/settings-controls.tsx @@ -1,6 +1,6 @@ // Reusable form controls for settings pages — sections, rows, toggles, sliders, etc. -import { useState } from 'react' +import { useRef, useState } from 'react' import { cn } from '@/lib/utils' export function SettingsSection({ @@ -210,3 +210,114 @@ export function RangeSlider({ ) } + +function nearestIndex(values: readonly number[], value: number): number { + let best = 0 + for (let i = 1; i < values.length; i++) { + if (Math.abs(values[i] - value) < Math.abs(values[best] - value)) best = i + } + return best +} + +// Discrete slider over an ascending allowed-values list plus a numeric input. +// The slider steps by index (one stop per arrow press, aria-valuetext announces +// the real value); the numeric input accepts any integer in range (no snapping). +// Pointer drags defer commit until release; keyboard changes commit immediately. +export function SteppedRangeInput({ + value, + values, + onChange, + 'aria-label': ariaLabel, + unit = '', + width = 'w-full md:w-32', +}: { + value: number + values: readonly number[] + onChange: (value: number) => void + 'aria-label': string + unit?: string + width?: string +}) { + const [pendingIndex, setPendingIndex] = useState(null) + const [draft, setDraft] = useState(null) + const pointerActive = useRef(false) + + const min = values[0] + const max = values[values.length - 1] + const displayValue = pendingIndex !== null ? values[pendingIndex] : value + + const commitPending = () => { + pointerActive.current = false + if (pendingIndex !== null) { + onChange(values[pendingIndex]) + setPendingIndex(null) + } + } + + const commitDraft = () => { + if (draft === null) return + setDraft(null) + const trimmed = draft.trim() + if (trimmed === '') return + const parsed = Number(trimmed) + if (!Number.isFinite(parsed)) return + const result = Math.min(max, Math.max(min, Math.round(parsed))) + if (result !== value) onChange(result) + } + + return ( +
+ { + const idx = Number(e.target.value) + if (pointerActive.current) { + setPendingIndex(idx) + } else { + onChange(values[idx]) + } + }} + onPointerDown={() => { + pointerActive.current = true + }} + onPointerUp={commitPending} + onPointerLeave={commitPending} + className={cn( + width, + 'h-1.5 bg-muted rounded-full appearance-none cursor-pointer', + '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground' + )} + /> + setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitDraft() + } else if (e.key === 'Escape') { + setDraft(null) + } + }} + className="h-10 w-16 px-2 text-right text-sm tabular-nums bg-muted border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border md:h-8" + /> + {unit && ( + + )} +
+ ) +} diff --git a/test/unit/client/components/SettingsView.behavior.test.tsx b/test/unit/client/components/SettingsView.behavior.test.tsx index fccaec76c..f7c5f31a9 100644 --- a/test/unit/client/components/SettingsView.behavior.test.tsx +++ b/test/unit/client/components/SettingsView.behavior.test.tsx @@ -63,17 +63,72 @@ describe('SettingsView behavior sections', () => { const store = createSettingsViewStore() renderSettingsView(store) - const uiScaleSlider = getSlider((slider) => { - const min = slider.getAttribute('min') - const step = slider.getAttribute('step') - return min === '0.75' && step === '0.05' - }) + const uiScaleSlider = screen.getByRole('slider', { name: 'UI scale' }) - fireEvent.change(uiScaleSlider, { target: { value: '1.5' } }) + fireEvent.change(uiScaleSlider, { target: { value: '33' } }) fireEvent.pointerUp(uiScaleSlider) - expect(store.getState().settings.settings.uiScale).toBe(1.5) - expect(screen.getByText('150%')).toBeInTheDocument() + expect(store.getState().settings.settings.uiScale).toBe(4) + const spinbutton = screen.getByRole('spinbutton', { name: 'UI scale' }) as HTMLInputElement + expect(spinbutton.value).toBe('400') + }) + + it('commits keyboard-only slider changes across the 200% boundary', () => { + const store = createSettingsViewStore({ settings: { uiScale: 2.0 } }) + renderSettingsView(store) + + const uiScaleSlider = screen.getByRole('slider', { name: 'UI scale' }) + + // No pointer events: keyboard changes must commit immediately. + fireEvent.change(uiScaleSlider, { target: { value: '26' } }) + + expect(store.getState().settings.settings.uiScale).toBe(2.25) + }) + + it('clamps numeric UI scale input to the supported range without calling /api/settings', async () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' }) + + fireEvent.change(uiScaleInput, { target: { value: '999' } }) + fireEvent.blur(uiScaleInput) + expect(store.getState().settings.settings.uiScale).toBe(4) + + fireEvent.change(uiScaleInput, { target: { value: '50' } }) + fireEvent.keyDown(uiScaleInput, { key: 'Enter' }) + expect(store.getState().settings.settings.uiScale).toBe(0.75) + + await act(async () => { + vi.advanceTimersByTime(500) + }) + + expect(api.patch).not.toHaveBeenCalled() + }) + + it('commits typed off-list UI scale percentages without snapping', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + + const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' }) + + fireEvent.change(uiScaleInput, { target: { value: '137' } }) + fireEvent.blur(uiScaleInput) + + expect(store.getState().settings.settings.uiScale).toBeCloseTo(1.37) + }) + + it('ignores invalid numeric UI scale input', () => { + const store = createSettingsViewStore() + renderSettingsView(store) + const initialUiScale = store.getState().settings.settings.uiScale + + const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' }) + + fireEvent.change(uiScaleInput, { target: { value: 'abc' } }) + fireEvent.blur(uiScaleInput) + + expect(store.getState().settings.settings.uiScale).toBe(initialUiScale) }) it('updates sidebar sort mode locally without calling /api/settings', async () => { diff --git a/test/unit/client/components/SettingsView.core.test.tsx b/test/unit/client/components/SettingsView.core.test.tsx index 8606c34b5..7dda358e5 100644 --- a/test/unit/client/components/SettingsView.core.test.tsx +++ b/test/unit/client/components/SettingsView.core.test.tsx @@ -208,7 +208,9 @@ describe('SettingsView core sections', () => { const store = createSettingsViewStore({ settings: { uiScale: 1.5 } }) renderSettingsView(store) - expect(screen.getByText('150%')).toBeInTheDocument() + const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' }) as HTMLInputElement + expect(uiScaleInput.value).toBe('150') + expect(screen.getByRole('slider', { name: 'UI scale' }).getAttribute('aria-valuetext')).toBe('150%') }) it('displays current line height value', () => { diff --git a/test/unit/client/components/SteppedRangeInput.test.tsx b/test/unit/client/components/SteppedRangeInput.test.tsx new file mode 100644 index 000000000..5b0d130e6 --- /dev/null +++ b/test/unit/client/components/SteppedRangeInput.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { SteppedRangeInput } from '@/components/settings/settings-controls' + +// Small fixture with a dual-rate gap (200 -> 225) mirroring the UI-scale options. +const VALUES = [75, 80, 200, 225, 250] + +function renderControl({ value = 80, onChange = vi.fn() }: { value?: number; onChange?: ReturnType } = {}) { + render( + + ) + return { + onChange, + slider: screen.getByRole('slider', { name: 'Test scale' }) as HTMLInputElement, + spin: screen.getByRole('spinbutton', { name: 'Test scale' }) as HTMLInputElement, + } +} + +afterEach(() => { + cleanup() +}) + +describe('SteppedRangeInput', () => { + describe('slider element', () => { + it('exposes an index-based range with aria-valuetext announcing the display value', () => { + const { slider } = renderControl({ value: 80 }) + + expect(slider.getAttribute('min')).toBe('0') + expect(slider.getAttribute('max')).toBe('4') + expect(slider.getAttribute('step')).toBe('1') + expect(slider.value).toBe('1') + expect(slider.getAttribute('aria-valuetext')).toBe('80%') + }) + + it('renders the nearest stop index for an off-list value', () => { + const { slider } = renderControl({ value: 220 }) + + // |220 - 200| = 20 vs |220 - 225| = 5 -> index 3 + expect(slider.value).toBe('3') + }) + + it('resolves nearest-stop ties to the lower index', () => { + const { slider } = renderControl({ value: 77.5 }) + + // Equidistant between 75 (index 0) and 80 (index 1) -> lower index wins. + expect(slider.value).toBe('0') + }) + }) + + describe('spinbutton element', () => { + it('shows the committed value exactly, including off-list values', () => { + const { spin, slider } = renderControl({ value: 137 }) + + expect(spin.value).toBe('137') + // Slider snaps its rendering to the nearest stop without touching the value. + expect(slider.value).toBe('1') + expect(slider.getAttribute('aria-valuetext')).toBe('137%') + }) + }) + + describe('pointer drag', () => { + it('defers commit until pointer-up while live-previewing the pending stop', () => { + const { onChange, slider, spin } = renderControl({ value: 80 }) + + fireEvent.pointerDown(slider) + fireEvent.change(slider, { target: { value: '3' } }) + + expect(onChange).not.toHaveBeenCalled() + expect(spin.value).toBe('225') + expect(slider.getAttribute('aria-valuetext')).toBe('225%') + + fireEvent.pointerUp(slider) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(225) + }) + }) + + describe('keyboard changes', () => { + it('commits immediately when the slider changes without an active pointer', () => { + const { onChange, slider } = renderControl({ value: 80 }) + + fireEvent.change(slider, { target: { value: '2' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(200) + }) + }) + + describe('numeric input commits', () => { + it('clamps below-range input up to the minimum on blur', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '50' } }) + fireEvent.blur(spin) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(75) + }) + + it('clamps above-range input down to the maximum on Enter', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '999' } }) + fireEvent.keyDown(spin, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(250) + }) + + it('commits typed off-list values without snapping to a stop', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '137' } }) + fireEvent.blur(spin) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(137) + }) + + it('reverts non-numeric or empty input on blur without committing', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '' } }) + fireEvent.blur(spin) + + expect(onChange).not.toHaveBeenCalled() + expect(spin.value).toBe('80') + }) + + it('reverts the draft on Escape without committing', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '123' } }) + fireEvent.keyDown(spin, { key: 'Escape' }) + + expect(onChange).not.toHaveBeenCalled() + expect(spin.value).toBe('80') + + // A subsequent blur must not commit the reverted draft either. + fireEvent.blur(spin) + expect(onChange).not.toHaveBeenCalled() + }) + + it('does not call onChange when committing the unchanged value', () => { + const { onChange, spin } = renderControl({ value: 80 }) + + fireEvent.change(spin, { target: { value: '80' } }) + fireEvent.blur(spin) + + expect(onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/unit/client/store/state-edge-cases.test.ts b/test/unit/client/store/state-edge-cases.test.ts index 837015cb7..1f9cf6fdd 100644 --- a/test/unit/client/store/state-edge-cases.test.ts +++ b/test/unit/client/store/state-edge-cases.test.ts @@ -926,7 +926,7 @@ describe('State Edge Cases', () => { }) ) - expect(store.getState().settings.settings.uiScale).toBe(1.5) + expect(store.getState().settings.settings.uiScale).toBe(4) expect(store.getState().settings.settings.terminal.fontSize).toBe(32) }) }) diff --git a/test/unit/shared/settings.test.ts b/test/unit/shared/settings.test.ts index 284e81f7b..67c75801d 100644 --- a/test/unit/shared/settings.test.ts +++ b/test/unit/shared/settings.test.ts @@ -9,6 +9,7 @@ import { mergeServerSettings, resolveLocalSettings, stripLocalSettings, + UI_SCALE_PERCENT_OPTIONS, } from '@shared/settings' describe('shared settings contract', () => { @@ -442,6 +443,33 @@ describe('shared settings contract', () => { }) }) + it('clamps oversized uiScale to the 400% maximum when extracting a legacy seed', () => { + expect(extractLegacyLocalSettingsSeed({ + uiScale: 999, + })).toEqual({ + uiScale: 4, + }) + }) + + it('pins the UI scale percent options: 5% steps to 200, 25% steps to 400', () => { + expect(UI_SCALE_PERCENT_OPTIONS).toEqual([ + 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145, 150, + 155, 160, 165, 170, 175, 180, 185, 190, 195, 200, + 225, 250, 275, 300, 325, 350, 375, 400, + ]) + + // Invariants: ascending integers spanning the shared clamp range. + expect(UI_SCALE_PERCENT_OPTIONS[0]).toBe(75) + expect(UI_SCALE_PERCENT_OPTIONS[UI_SCALE_PERCENT_OPTIONS.length - 1]).toBe(400) + for (let i = 0; i < UI_SCALE_PERCENT_OPTIONS.length; i++) { + expect(Number.isInteger(UI_SCALE_PERCENT_OPTIONS[i])).toBe(true) + if (i === 0) continue + const delta = UI_SCALE_PERCENT_OPTIONS[i] - UI_SCALE_PERCENT_OPTIONS[i - 1] + expect(delta).toBeGreaterThan(0) + expect(delta).toBe(UI_SCALE_PERCENT_OPTIONS[i] <= 200 ? 5 : 25) + } + }) + it('strips moved local settings while preserving server-backed settings', () => { const rawMixedSettings = { theme: 'dark',