Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1ea9905
feat(ux): replace native titles with tooltips in PracticeProgress
seonghobae Sep 16, 2026
9ae8055
test(workspace): require disabled progress reasons
seonghobae Sep 16, 2026
5914618
fix(workspace): describe progress limits without replacing action names
seonghobae Sep 16, 2026
d345e93
feat(i18n): explain practice progress limits in English
seonghobae Sep 16, 2026
20e7dea
feat(i18n): explain practice progress limits in Korean
seonghobae Sep 16, 2026
a627d0b
fix(workspace): keep tooltip labels aligned with progress actions
seonghobae Sep 16, 2026
c55435c
feat(ux): replace native titles with tooltips in PracticeProgress
seonghobae Sep 16, 2026
02c9eef
fix(workspace): restore progress boundary accessibility contract
seonghobae Sep 16, 2026
63b12f1
test(workspace): require practical slider pointer target
seonghobae Sep 16, 2026
1dbf9f8
fix(workspace): enlarge practice slider hit target
seonghobae Sep 16, 2026
e3547dc
test(workspace): require 44px practice progress targets
seonghobae Sep 16, 2026
08e9630
fix(workspace): meet enhanced progress target size
seonghobae Sep 16, 2026
240d0c5
feat(ux): replace native titles with tooltips in PracticeProgress
seonghobae Sep 16, 2026
ec05189
test(workspace): restore practice-progress accessibility contract
seonghobae Sep 16, 2026
fd48704
fix(workspace): restore practice-progress boundary semantics
seonghobae Sep 16, 2026
78440c5
fix(i18n): restore practice-progress limit reasons
seonghobae Sep 16, 2026
c600ed7
fix(i18n): restore Korean practice-progress limit reasons
seonghobae Sep 16, 2026
d2cf8db
repair(workspace): release formatter ownership to #1176
seonghobae Sep 16, 2026
dd6679e
feat(ux): replace native titles with tooltips in PracticeProgress and…
seonghobae Sep 17, 2026
d9ad3b7
feat(ux): replace native titles with tooltips in PracticeProgress and…
seonghobae Sep 17, 2026
15f1b85
feat(ux): replace native titles with tooltips in PracticeProgress and…
seonghobae Sep 18, 2026
cc45a29
feat(ux): replace native titles with tooltips in PracticeProgress and…
seonghobae Sep 18, 2026
d839aa9
repair(workspace): restore practice-progress accessibility contract
seonghobae Sep 18, 2026
66b1a0b
test(workspace): exercise practice progress tooltip interactions
seonghobae Sep 21, 2026
0f7cf41
feat(workspace): compose accessible Practice Progress slider
seonghobae Sep 23, 2026
c30665f
🎨 Palette: 연습 진행도 조작 버튼에 접근성 향상을 위한 Tooltip 적용
seonghobae Sep 24, 2026
25e5d3d
repair(workspace): restore canonical Practice Progress composition
seonghobae Sep 24, 2026
71838d2
🎨 Palette: 연습 진행도 조작 버튼에 접근성 향상을 위한 Tooltip 적용
seonghobae Sep 24, 2026
0372884
repair(ui): drop local check transcript from PracticeProgress owner
seonghobae Sep 24, 2026
4d5a891
trigger review
seonghobae Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 79 additions & 41 deletions apps/desktop/src/features/workspace/PracticeProgress.test.tsx
Original file line number Diff line number Diff line change
@@ -1,111 +1,149 @@
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PracticeProgress } from "./PracticeProgress";
import userEvent from "@testing-library/user-event";

// Mock the i18n functions
vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) => key,
detectPreferredLocale: () => "en-US",
}));

describe("PracticeProgress", () => {
it("renders with default progress 0 when no progress is provided", () => {
it("renders the minimum boundary with a persistent accessible reason", () => {
const handleChange = vi.fn();
render(<PracticeProgress onChange={handleChange} />);

expect(screen.getByText("0%")).toBeTruthy();
const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }) as HTMLButtonElement;
const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" });
expect(decreaseBtn).toHaveAttribute("aria-disabled", "true");
expect(decreaseBtn).not.toHaveAttribute("title");

const descriptionId = decreaseBtn.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")).toHaveTextContent("practiceProgressAtMin");

const clickEvent = createEvent.click(decreaseBtn);
fireEvent(decreaseBtn, clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(handleChange).not.toHaveBeenCalled();
});

it("renders provided progress", () => {
it("uses the visible practice-progress label as the slider accessible name", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);

expect(screen.getByRole("slider", { name: "practiceProgressLabel" })).toBeInTheDocument();
expect(screen.getByText("50%")).toBeTruthy();
});

it("calls onChange with increased value when increase button is clicked", () => {
it("shows the boundary tooltip on keyboard focus and dismisses it with Escape", async () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);
render(<PracticeProgress progress={0} onChange={handleChange} />);

const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" });
fireEvent.click(increaseBtn);

expect(handleChange).toHaveBeenCalledWith(60);
const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" });
fireEvent.focus(decreaseBtn);

expect(
await screen.findByText("decreasePracticeProgressLabel: practiceProgressAtMin"),
).toBeInTheDocument();

fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(
screen.queryByText("decreasePracticeProgressLabel: practiceProgressAtMin"),
).not.toBeInTheDocument();
});
});

it("calls onChange with decreased value when decrease button is clicked", () => {
it("shows the action tooltip after pointer hover", async () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);

const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" });
fireEvent.click(decreaseBtn);
const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" });
await userEvent.hover(increaseBtn);

expect(handleChange).toHaveBeenCalledWith(40);
expect(await screen.findByText("increasePracticeProgressLabel")).toBeInTheDocument();
});

it("does not exceed 100 when increasing", () => {
it("changes progress from the increment and decrement controls", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={95} onChange={handleChange} />);
render(<PracticeProgress progress={50} onChange={handleChange} />);

const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" });
fireEvent.click(increaseBtn);
fireEvent.click(screen.getByRole("button", { name: "increasePracticeProgressLabel" }));
expect(handleChange).toHaveBeenLastCalledWith(60);

expect(handleChange).toHaveBeenCalledWith(100);
fireEvent.click(screen.getByRole("button", { name: "decreasePracticeProgressLabel" }));
expect(handleChange).toHaveBeenLastCalledWith(40);
});

it("does not go below 0 when decreasing", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={5} onChange={handleChange} />);
it("clamps button changes to the 0-100 range", () => {
const handleIncrease = vi.fn();
const { rerender } = render(<PracticeProgress progress={95} onChange={handleIncrease} />);

const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" });
fireEvent.click(decreaseBtn);
fireEvent.click(screen.getByRole("button", { name: "increasePracticeProgressLabel" }));
expect(handleIncrease).toHaveBeenCalledWith(100);

expect(handleChange).toHaveBeenCalledWith(0);
const handleDecrease = vi.fn();
rerender(<PracticeProgress progress={5} onChange={handleDecrease} />);
fireEvent.click(screen.getByRole("button", { name: "decreasePracticeProgressLabel" }));
expect(handleDecrease).toHaveBeenCalledWith(0);
});

it("calls onChange when slider is changed", () => {
it("changes progress through the Base UI slider keyboard contract", async () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);

const slider = screen.getByRole("slider");
fireEvent.change(slider, { target: { value: "75" } });
const slider = screen.getByRole("slider", { name: "practiceProgressLabel" });
await act(async () => {
slider.focus();
fireEvent.keyDown(slider, { key: "ArrowRight", code: "ArrowRight" });
});

expect(handleChange).toHaveBeenCalledWith(75);
expect(handleChange).toHaveBeenCalledWith(51);
});

it("keeps focus on interactive controls instead of the progress region", () => {
it("keeps 44 CSS px interaction envelopes without making the visible track oversized", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);
const { container } = render(<PracticeProgress progress={50} onChange={handleChange} />);

expect(screen.getByRole("region", { name: "practiceProgressRegionLabel" })).not.toHaveAttribute("tabindex");
expect(screen.getByRole("slider")).toBeInTheDocument();
const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" });
const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" });
const slider = screen.getByRole("slider", { name: "practiceProgressLabel" });
const thumb = slider.parentElement;
const control = container.querySelector('[data-slot="slider-control"]');
const track = container.querySelector('[data-slot="slider-track"]');

expect(decreaseBtn).toHaveClass("size-11");
expect(increaseBtn).toHaveClass("size-11");
expect(control).toHaveClass("h-11", "min-h-11");
expect(track).toHaveClass("h-3");
expect(track).not.toHaveClass("overflow-hidden");
expect(thumb).toHaveClass("after:inset-[-12px]");
});

it("ignores invalid slider input gracefully", () => {
it("keeps focus on interactive controls instead of the progress region", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);

const slider = screen.getByRole("slider");
fireEvent.change(slider, { target: { value: "invalid" } });

expect(handleChange).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "practiceProgressRegionLabel" })).not.toHaveAttribute("tabindex");
expect(screen.getByRole("slider", { name: "practiceProgressLabel" })).toBeInTheDocument();
});

it("disables increase button when progress is 100", () => {
it("keeps the maximum boundary focusable with a persistent accessible reason", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={100} onChange={handleChange} />);

const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }) as HTMLButtonElement;
const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" });
expect(increaseBtn).toHaveAttribute("aria-disabled", "true");
expect(increaseBtn).not.toHaveAttribute("title");

const descriptionId = increaseBtn.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")).toHaveTextContent("practiceProgressAtMax");

const clickEvent = createEvent.click(increaseBtn);
fireEvent(increaseBtn, clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(handleChange).not.toHaveBeenCalled();
});
});
143 changes: 89 additions & 54 deletions apps/desktop/src/features/workspace/PracticeProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useId } from "react";
import { Minus, Plus } from "lucide-react";
import {
Slider,
SliderControl,
SliderIndicator,
SliderLabel,
SliderThumb,
SliderTrack,
} from "../../components/ui/slider";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

/** Documented. */
/** Properties for the rehearsal practice-progress control. */
interface PracticeProgressProps {
progress?: number;
onChange: (newProgress: number) => void;
}

/** Documented. */
/** Render the rehearsal practice-progress controls and accessible boundary cues. */
function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressProps) {
const t = createTranslator(detectPreferredLocale());
const decreaseLimitDescriptionId = useId();
const increaseLimitDescriptionId = useId();
const atMinimum = progress <= 0;
const atMaximum = progress >= 100;

const handleDecrease = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
if (progress <= 0) {
Expand All @@ -28,69 +41,91 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP
onChange(Math.min(100, progress + 10));
}, [progress, onChange]);

const handleSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!Number.isNaN(value)) {
onChange(Math.max(0, Math.min(100, value)));
const handleSliderChange = useCallback((value: number | readonly number[]) => {
const numericValue = Array.isArray(value) ? value[0] : value;
if (typeof numericValue !== "number" || !Number.isFinite(numericValue)) {
return;
}
onChange(Math.max(0, Math.min(100, numericValue)));
}, [onChange]);

const decreaseActionLabel = t("decreasePracticeProgressLabel");
const increaseActionLabel = t("increasePracticeProgressLabel");
const minimumReason = t("practiceProgressAtMin");
const maximumReason = t("practiceProgressAtMax");

return (
<div
className="mt-4 rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-4 focus-within:ring-2 focus-within:ring-indigo-300"
role="region"
aria-label={t("practiceProgressRegionLabel")}
>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="practice-progress-slider" className="text-xs font-black uppercase tracking-[0.24em] text-indigo-200">
{t("practiceProgressLabel")}
</label>
<span className="text-sm font-semibold text-slate-200">{progress}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={progress}
onValueChange={handleSliderChange}
className="block w-full"
>
<div className="mb-2 flex items-center justify-between">
<SliderLabel className="text-xs font-black uppercase tracking-[0.24em] text-indigo-200">
{t("practiceProgressLabel")}
</SliderLabel>
<span className="text-sm font-semibold text-slate-200">{progress}%</span>
</div>

<div className="flex items-center gap-4">
<button
type="button"
onClick={handleDecrease}
aria-disabled={progress <= 0 ? "true" : undefined}
className="flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
aria-label={t("decreasePracticeProgressLabel")}
title={t("decreasePracticeProgressLabel")}
>
<Minus className="size-4" aria-hidden="true" />
</button>
<div className="flex items-center gap-4">
<Tooltip>
<TooltipTrigger
type="button"
onClick={handleDecrease}
aria-disabled={atMinimum ? "true" : undefined}
aria-describedby={atMinimum ? decreaseLimitDescriptionId : undefined}
className="flex size-11 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
aria-label={decreaseActionLabel}
>
<Minus className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
{atMinimum ? `${decreaseActionLabel}: ${minimumReason}` : decreaseActionLabel}
</TooltipContent>
</Tooltip>
{atMinimum ? (
<span id={decreaseLimitDescriptionId} className="sr-only">
{minimumReason}
</span>
) : null}

<div className="relative h-3 flex-1 overflow-hidden rounded-full bg-slate-900/50 shadow-inner">
<div
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-indigo-500 to-cyan-400 transition-all duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
<input
id="practice-progress-slider"
type="range"
min="0"
max="100"
step="1"
value={progress}
onChange={handleSliderChange}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
<SliderControl className="h-11 min-h-11 flex-1">
<SliderTrack className="h-3 bg-slate-900/50 shadow-inner">
<SliderIndicator className="bg-gradient-to-r from-indigo-500 to-cyan-400 transition-all duration-200 ease-out" />
<SliderThumb />
</SliderTrack>
</SliderControl>

<button
type="button"
onClick={handleIncrease}
aria-disabled={progress >= 100 ? "true" : undefined}
className="flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
aria-label={t("increasePracticeProgressLabel")}
title={t("increasePracticeProgressLabel")}
>
<Plus className="size-4" aria-hidden="true" />
</button>
</div>
<Tooltip>
<TooltipTrigger
type="button"
onClick={handleIncrease}
aria-disabled={atMaximum ? "true" : undefined}
aria-describedby={atMaximum ? increaseLimitDescriptionId : undefined}
className="flex size-11 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
aria-label={increaseActionLabel}
>
<Plus className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
{atMaximum ? `${increaseActionLabel}: ${maximumReason}` : increaseActionLabel}
</TooltipContent>
</Tooltip>
{atMaximum ? (
<span id={increaseLimitDescriptionId} className="sr-only">
{maximumReason}
</span>
) : null}
</div>
</Slider>
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
"practiceProgressAtMin": "Progress is already at 0%.",
"practiceProgressAtMax": "Progress is already at 100%.",
"workspaceFirstRangeTitle": "Tonight's first range",
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
"practiceProgressAtMin": "진척도가 이미 0%입니다.",
"practiceProgressAtMax": "진척도가 이미 100%입니다.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
Expand Down