From 6cca6943fef1d49eca46dc5dfe4de67eb7511372 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 23 Sep 2026 20:10:55 +0100 Subject: [PATCH 1/2] Show pointer speed on an accessible level scale --- src/settings/PointerSection.tsx | 50 +++++------- src/settings/controls.tsx | 6 -- src/settings/pointerSpeedScale.ts | 22 +++++ src/settings/settings.test.tsx | 130 +++++++++++++++--------------- src/styles.css | 14 ++-- 5 files changed, 115 insertions(+), 107 deletions(-) create mode 100644 src/settings/pointerSpeedScale.ts diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx index 93a3e6cf..3434b58f 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -1,42 +1,30 @@ import { Button, Input, MoreOptions } from "../ui/controls"; -import { useEffect, useId, useState } from "react"; import type { AppSettings } from "../types"; import { - Disclosure, OptionGroup, SettingGroup, Toggle, accelerationOptions, - movementValue, pointerSpeedOptions, repeatIntervalOptions, secondsOptions, - type SettingsUpdate, + OptionGroup, SettingGroup, Toggle, accelerationOptions, repeatIntervalOptions, + secondsOptions, type SettingsUpdate, } from "./controls"; +import { speedLevelLabel, speedPercent } from "./pointerSpeedScale"; export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { - // Keep a draft so the user can type a multi-digit speed without saving each - // intermediate value or losing focus to a backend state update. - const isPreset = (pointerSpeedOptions as readonly number[]).includes(settings.pointerScalePercent); - const [showExact, setShowExact] = useState(!isPreset); - const [speedDraft, setSpeedDraft] = useState(String(settings.pointerScalePercent)); - useEffect(() => { setSpeedDraft(String(settings.pointerScalePercent)); }, [settings.pointerScalePercent]); - useEffect(() => { if (!isPreset) setShowExact(true); }, [isPreset]); - const exactSpeedId = useId(); - const saveExactSpeed = () => { - const entered = Number(speedDraft); - if (!Number.isFinite(entered) || entered <= 0) { - setSpeedDraft(String(settings.pointerScalePercent)); - return; - } - const next = Math.min(1350, Math.max(5, Math.round(entered / 5) * 5)); - setSpeedDraft(String(next)); - if (next !== settings.pointerScalePercent) update("pointerScalePercent", next); - }; + const percent = settings.pointerScalePercent; + const level = speedLevelLabel(percent); return -
Pointer speed {settings.pointerScalePercent}%

100% is the original speed. Higher speeds help cross the screen faster in Mouse and Remote.

- {pointerSpeedOptions.map((value) => )} -
setShowExact(!showExact)} controls={exactSpeedId}> -
- -
- {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)} +
Pointer speed Level {level} of 10 +

Move the slider toward Fast to cover more distance with each step in Mouse scanning and Remote.

+ { + const next = speedPercent(Number(event.target.value)); + if (next !== percent) update("pointerScalePercent", next); + }} /> + +
+

Adjust the speed in small steps.

+
+ +
-
-
+
+
update("mouseRepeatEnabled", value)} />
diff --git a/src/settings/controls.tsx b/src/settings/controls.tsx index 0de5897a..704dfa21 100644 --- a/src/settings/controls.tsx +++ b/src/settings/controls.tsx @@ -11,7 +11,6 @@ export function SettingGroup({ title, description, children, id, sectionRef, foc return

{title}

{description}

{children}
; } -export const pointerSpeedOptions = [5, 25, 50, 75, 100, 500, 1350] as const; export const repeatIntervalOptions = [100, 250, 500, 1000] as const; export const keyRepeatDelayOptions = [ { value: 0, label: "None" }, @@ -27,11 +26,6 @@ export const accelerationOptions = [ ] as const; export const dwellDelayOptions = [500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000] as const; -export function movementValue(base: number, scale: number) { - const value = Math.max(1, Math.round((base * scale / 100) * 2) / 2); - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - // `note` renders beside the fieldset rather than inside it, by construction: a // disclosure inside a disabled fieldset would inherit `disabled` and become // unreachable. The fieldset keeps its programmatic link to the note through diff --git a/src/settings/pointerSpeedScale.ts b/src/settings/pointerSpeedScale.ts new file mode 100644 index 00000000..26cbdc7e --- /dev/null +++ b/src/settings/pointerSpeedScale.ts @@ -0,0 +1,22 @@ +const minSpeed = 5; +const normalSpeed = 100; +const maxSpeed = 1350; + +export function speedLevel(percent: number): number { + if (percent <= normalSpeed) { + return 1 + 4 * Math.log(Math.max(minSpeed, percent) / minSpeed) / Math.log(normalSpeed / minSpeed); + } + return 5 + 5 * Math.log(Math.min(maxSpeed, percent) / normalSpeed) / Math.log(maxSpeed / normalSpeed); +} + +export function speedPercent(level: number): number { + const bounded = Math.min(10, Math.max(1, level)); + const value = bounded <= 5 + ? minSpeed * (normalSpeed / minSpeed) ** ((bounded - 1) / 4) + : normalSpeed * (maxSpeed / normalSpeed) ** ((bounded - 5) / 5); + return Math.min(maxSpeed, Math.max(minSpeed, Math.round(value / 5) * 5)); +} + +export function speedLevelLabel(percent: number): string { + return speedLevel(percent).toFixed(1); +} diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index dd9ede36..ef837703 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App"; import { api, browserState } from "../api"; import type { AppSettings } from "../types"; +import { speedLevel, speedPercent } from "./pointerSpeedScale"; const defaultBrowserSettings = structuredClone(browserState.settings); const defaultCapabilities = structuredClone(browserState.capabilities); @@ -12,6 +13,7 @@ function stateWithSettings(settings: AppSettings) { } const selectTab = (name: string) => fireEvent.click(screen.getByRole("tab", { name })); +const setPointerSpeed = (percent: number) => fireEvent.change(screen.getByRole("slider", { name: "Pointer speed" }), { target: { value: speedLevel(percent).toFixed(1) } }); describe("Switchify PC settings", () => { beforeEach(() => { @@ -180,22 +182,15 @@ describe("Switchify PC settings", () => { expect(screen.queryByRole("checkbox", { name: "Click when I stop" })).not.toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked(); fireEvent.click(screen.getByRole("button", { name: "Repeat timing" })); - expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("5.0"); expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked(); expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled(); expect(screen.getAllByRole("button", { name: "Medium" })[0]).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - - // Exact speed and the movement readout sit behind a disclosure by default. - expect(screen.queryByRole("spinbutton", { name: "Exact pointer speed" })).not.toBeInTheDocument(); - expect(screen.queryByText("2.5")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Set an exact speed" })); - expect(screen.getByText("2.5")).toBeInTheDocument(); - fireEvent.change(screen.getByRole("spinbutton", { name: "Exact pointer speed" }), { target: { value: "125" } }); - fireEvent.blur(screen.getByRole("spinbutton", { name: "Exact pointer speed" })); - expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(125); - expect(screen.getByText("5.5")).toBeInTheDocument(); + setPointerSpeed(50); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("4.1"); + fireEvent.click(screen.getByRole("button", { name: "Fine tune speed" })); + fireEvent.click(screen.getByRole("button", { name: "Faster" })); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveAttribute("aria-valuetext", "Level 4.2 of 10"); fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); }); @@ -312,18 +307,18 @@ describe("Switchify PC settings", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); - fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" })); + setPointerSpeed(50); + setPointerSpeed(75); expect(saves).toHaveLength(1); - expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("4.6"); await act(async () => { saves[0].resolve(stateWithSettings(saves[0].settings)); }); await waitFor(() => expect(saves).toHaveLength(2)); expect(saves[1].settings.pointerScalePercent).toBe(75); - expect(screen.getByRole("button", { name: "75% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("4.6"); await act(async () => { saves[1].resolve(stateWithSettings(saves[1].settings)); @@ -343,7 +338,7 @@ describe("Switchify PC settings", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + setPointerSpeed(50); const runtimeState = { ...structuredClone(browserState), bluetooth: "connected" as const, @@ -375,10 +370,10 @@ describe("Switchify PC settings", () => { selectTab("Privacy"); fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); fireEvent.click(screen.getByRole("button", { name: "Mouse" })); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + setPointerSpeed(50); act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 }))); - expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(150); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("5.8"); fireEvent.click(screen.getByRole("button", { name: "Settings" })); selectTab("Privacy"); expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); @@ -514,12 +509,12 @@ describe("Switchify PC settings", () => { fireEvent.click(await screen.findByRole("button", { name: "Settings" })); fireEvent.click(screen.getByRole("button", { name: "Mouse" })); - fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + setPointerSpeed(50); fireEvent.click(screen.getByRole("button", { name: "Settings" })); selectTab("Privacy"); fireEvent.click(screen.getByRole("button", { name: "Mouse" })); - expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("4.1"); await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 50 }))); }); @@ -614,50 +609,50 @@ describe("Switchify PC settings", () => { expect(panel).toHaveFocus(); }); - it("keeps the exact speed disclosure collapsed for a preset value", async () => { + it("shows an accessible Slow–Fast scale and keeps fine tuning collapsed", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - - const toggle = screen.getByRole("button", { name: "Set an exact speed" }); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - // The active value stays legible in the legend even while collapsed. - expect(screen.getByRole("group", { name: /Pointer speed/ })).toHaveTextContent("100%"); - - // Collapsed, the target is unmounted, so the button must not point at it. - expect(toggle).not.toHaveAttribute("aria-controls"); - fireEvent.click(toggle); - const hide = screen.getByRole("button", { name: "Hide exact speed" }); - expect(hide).toHaveAttribute("aria-expanded", "true"); - expect(document.getElementById(hide.getAttribute("aria-controls")!)).toContainElement(screen.getByRole("spinbutton", { name: "Exact pointer speed" })); - expect(screen.getByLabelText("Pointer movement values")).toBeInTheDocument(); - }); - - it("expands the exact speed disclosure for a value the presets cannot reach", async () => { + const slider = screen.getByRole("slider", { name: "Pointer speed" }); + expect(slider).toHaveValue("5.0"); + expect(slider).toHaveAttribute("aria-valuetext", "Level 5.0 of 10"); + expect(screen.getByRole("group", { name: /Pointer speed/ })).toHaveTextContent("Level 5.0 of 10"); + expect(screen.getByText("Slow")).toBeInTheDocument(); + expect(screen.getByText("Fast")).toBeInTheDocument(); + expect(screen.queryByText(/100%/)).not.toBeInTheDocument(); + const disclosure = screen.getByRole("button", { name: "Fine tune speed" }); + expect(disclosure).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(disclosure); + expect(disclosure).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: "Slower" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Faster" })).toBeEnabled(); + }); + + it("preserves older saved speeds until the user changes them", async () => { + const save = vi.spyOn(api, "saveSettings"); browserState.settings = { ...structuredClone(defaultBrowserSettings), pointerScalePercent: 150 }; render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - - expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(150); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("5.8"); + expect(save).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Fine tune speed" })); + fireEvent.click(screen.getByRole("button", { name: "Faster" })); + await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 155 }))); }); - it("offers fast presets and rounds exact speed to the supported five percent step", async () => { + it("maps both slider endpoints and stops fine tuning at the limits", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - fireEvent.click(screen.getByRole("button", { name: "1350% pointer speed" })); - expect(screen.getByRole("button", { name: "1350% pointer speed" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "Set an exact speed" })); - const exact = screen.getByRole("spinbutton", { name: "Exact pointer speed" }); - expect(exact).toHaveAttribute("min", "5"); - expect(exact).toHaveAttribute("max", "1350"); - expect(exact).toHaveAttribute("step", "5"); - fireEvent.change(exact, { target: { value: "1337" } }); - fireEvent.blur(exact); - expect(exact).toHaveValue(1335); - expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); - }); - - it("expands the exact speed disclosure when the backend pushes a non-preset value", async () => { + const slider = screen.getByRole("slider", { name: "Pointer speed" }); + fireEvent.change(slider, { target: { value: "1" } }); + expect(slider).toHaveValue("1.0"); + fireEvent.click(screen.getByRole("button", { name: "Fine tune speed" })); + expect(screen.getByRole("button", { name: "Slower" })).toBeDisabled(); + fireEvent.change(slider, { target: { value: "10" } }); + expect(slider).toHaveValue("10.0"); + expect(screen.getByRole("button", { name: "Faster" })).toBeDisabled(); + }); + + it("reflects a backend speed change without overwriting it", async () => { let stateHandler: ((state: typeof browserState) => void) | undefined; vi.spyOn(api, "onState").mockImplementation(async (handler) => { stateHandler = handler; @@ -665,15 +660,24 @@ describe("Switchify PC settings", () => { }); render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); - expect(screen.getByRole("button", { name: "Set an exact speed" })).toBeInTheDocument(); - act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 175 }))); - - expect(screen.getByRole("button", { name: "Hide exact speed" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("spinbutton", { name: "Exact pointer speed" })).toHaveValue(175); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("6.1"); + }); + + it("maps speed levels monotonically to valid saved values", () => { + expect(speedPercent(1)).toBe(5); + expect(speedPercent(5)).toBe(100); + expect(speedPercent(10)).toBe(1350); + expect(speedLevel(100)).toBe(5); + let previous = 0; + for (let tenth = 10; tenth <= 100; tenth++) { + const percent = speedPercent(tenth / 10); + expect(percent).toBeGreaterThanOrEqual(previous); + expect(percent % 5).toBe(0); + previous = percent; + } }); - it("keeps the key repeat explanation behind a disclosure", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); diff --git a/src/styles.css b/src/styles.css index b19832a2..e824a4dc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -124,16 +124,16 @@ button:disabled { cursor: default; opacity: 0.55; } .pointer-speed legend, .repeat-options legend { width: 100%; margin-bottom: 8px; color: var(--muted); } .pointer-speed legend strong { float: right; color: inherit; } .segmented.compact.five { grid-template-columns: repeat(5, 1fr); } -.segmented.compact.speed-presets { grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); } .segmented.compact.four { grid-template-columns: repeat(4, 1fr); } -.segmented.compact.five button, .segmented.compact.four button, .segmented.compact.speed-presets button { min-width: 0; padding: 0 6px; } +.segmented.compact.five button, .segmented.compact.four button { min-width: 0; padding: 0 6px; } +.pointer-speed .pointer-speed-slider { width: 100%; padding: 0; accent-color: var(--brand); cursor: pointer; } +.pointer-speed-ends, .pointer-speed-steps { display: flex; justify-content: space-between; gap: 12px; } +.pointer-speed-ends { color: var(--muted); } +.pointer-speed-fine-tune { display: grid; gap: 12px; } +.pointer-speed-fine-tune p { margin: 0; } +.pointer-speed-steps button { flex: 1; } .exact-speed { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: var(--muted); } .exact-speed input { min-width: 92px; min-height: 32px; border: 1px solid var(--border); border-radius: 5px; padding: 4px 8px; color: inherit; background: var(--surface); } -.movement-values { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } -.movement-values div { display: grid; gap: 3px; padding: 10px 8px; text-align: center; } -.movement-values div + div { border-left: 1px solid var(--border); } -.movement-values span { color: var(--muted); } -.movement-values strong { font-weight: 650; } .repeat-settings { display: grid; gap: 17px; border-top: 1px solid var(--border); padding-top: 17px; } .repeat-options { display: grid; gap: 17px; } /* A disabled group dims itself and the prose of the note beside it, never a From 18ad08be9af3ee73fb0f9d0d854cdb5e9fc15f8f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 23 Sep 2026 20:15:06 +0100 Subject: [PATCH 2/2] Keep slider keyboard progress and announce fine steps --- src/settings/PointerSection.tsx | 22 ++++++++++++++++++---- src/settings/settings.test.tsx | 30 ++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx index 3434b58f..fff516b8 100644 --- a/src/settings/PointerSection.tsx +++ b/src/settings/PointerSection.tsx @@ -1,24 +1,38 @@ import { Button, Input, MoreOptions } from "../ui/controls"; +import { useEffect, useState } from "react"; import type { AppSettings } from "../types"; import { OptionGroup, SettingGroup, Toggle, accelerationOptions, repeatIntervalOptions, secondsOptions, type SettingsUpdate, } from "./controls"; -import { speedLevelLabel, speedPercent } from "./pointerSpeedScale"; +import { speedLevel, speedLevelLabel, speedPercent } from "./pointerSpeedScale"; export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { const percent = settings.pointerScalePercent; const level = speedLevelLabel(percent); + const [sliderLevel, setSliderLevel] = useState(level); + useEffect(() => { setSliderLevel(level); }, [level, percent]); + const changeSpeed = (next: number) => { + if (next !== percent) update("pointerScalePercent", next); + }; return
Pointer speed Level {level} of 10

Move the slider toward Fast to cover more distance with each step in Mouse scanning and Remote.

- { - const next = speedPercent(Number(event.target.value)); - if (next !== percent) update("pointerScalePercent", next); + { + setSliderLevel(event.target.value); + changeSpeed(speedPercent(Number(event.target.value))); + }} onKeyDown={(event) => { + const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 5 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -5 : 0; + if (!delta && event.key !== "Home" && event.key !== "End") return; + event.preventDefault(); + const next = event.key === "Home" ? 5 : event.key === "End" ? 1350 : Math.min(1350, Math.max(5, percent + delta)); + setSliderLevel(speedLevelLabel(next)); + changeSpeed(next); }} />

Adjust the speed in small steps.

+

Fine-tuned level {speedLevel(percent).toFixed(3)} of 10

diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index ef837703..f05fd168 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -190,7 +190,7 @@ describe("Switchify PC settings", () => { expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveValue("4.1"); fireEvent.click(screen.getByRole("button", { name: "Fine tune speed" })); fireEvent.click(screen.getByRole("button", { name: "Faster" })); - expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveAttribute("aria-valuetext", "Level 4.2 of 10"); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveAttribute("aria-valuetext", `Level ${speedLevel(55).toFixed(3)} of 10`); fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); }); @@ -614,7 +614,7 @@ describe("Switchify PC settings", () => { fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); const slider = screen.getByRole("slider", { name: "Pointer speed" }); expect(slider).toHaveValue("5.0"); - expect(slider).toHaveAttribute("aria-valuetext", "Level 5.0 of 10"); + expect(slider).toHaveAttribute("aria-valuetext", "Level 5.000 of 10"); expect(screen.getByRole("group", { name: /Pointer speed/ })).toHaveTextContent("Level 5.0 of 10"); expect(screen.getByText("Slow")).toBeInTheDocument(); expect(screen.getByText("Fast")).toBeInTheDocument(); @@ -639,6 +639,32 @@ describe("Switchify PC settings", () => { await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 155 }))); }); + it("advances from the slow endpoint with the keyboard and retains a drag position on a rounding plateau", async () => { + const save = vi.spyOn(api, "saveSettings"); + browserState.settings = { ...structuredClone(defaultBrowserSettings), pointerScalePercent: 5 }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); + const slider = screen.getByRole("slider", { name: "Pointer speed" }); + fireEvent.change(slider, { target: { value: "1.1" } }); + expect(slider).toHaveValue("1.1"); + expect(save).not.toHaveBeenCalled(); + fireEvent.keyDown(slider, { key: "ArrowRight" }); + await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 10 }))); + expect(slider).toHaveAttribute("aria-valuetext", `Level ${speedLevel(10).toFixed(3)} of 10`); + }); + + it("announces every fine tuning step even when the rounded main level stays the same", async () => { + browserState.settings = { ...structuredClone(defaultBrowserSettings), pointerScalePercent: 1300 }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Mouse" })); + fireEvent.click(screen.getByRole("button", { name: "Fine tune speed" })); + const before = screen.getByText(`Fine-tuned level ${speedLevel(1300).toFixed(3)} of 10`).textContent; + fireEvent.click(screen.getByRole("button", { name: "Faster" })); + expect(screen.getByText(`Fine-tuned level ${speedLevel(1305).toFixed(3)} of 10`)).toBeInTheDocument(); + expect(screen.getByText(`Fine-tuned level ${speedLevel(1305).toFixed(3)} of 10`).textContent).not.toBe(before); + expect(screen.getByRole("slider", { name: "Pointer speed" })).toHaveAttribute("aria-valuetext", `Level ${speedLevel(1305).toFixed(3)} of 10`); + }); + it("maps both slider endpoints and stops fine tuning at the limits", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Mouse" }));