(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 && (
+
+ {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',