Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
915db47
fix(a11y): keep unavailable score controls discoverable
seonghobae Aug 14, 2026
f34402a
fix(a11y): explain unavailable pagination states
seonghobae Aug 14, 2026
123f8d7
test(score): require disabled remove recovery hint
seonghobae Aug 16, 2026
df0809e
test(score): align disabled remove recovery hint
seonghobae Aug 16, 2026
fb2c9d8
fix(score): explain unavailable remove action
seonghobae Aug 16, 2026
98abea5
test(score): fold recovery hint regression into component suite
seonghobae Aug 16, 2026
b88a1e0
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 16, 2026
1ba37d7
test(a11y): restore localized disabled-action oracle
seonghobae Aug 16, 2026
8ea7ae2
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 16, 2026
81cc280
test(a11y): restore localized disabled-action oracle
seonghobae Aug 16, 2026
9bd7fdd
test(score): require disabled page navigation descriptions
seonghobae Aug 18, 2026
a00c32c
fix(score): describe unavailable page navigation
seonghobae Aug 18, 2026
0b9554a
docs(a11y): record disabled score navigation contract
seonghobae Aug 18, 2026
84f45fb
test(a11y): require enabled score action tooltips
seonghobae Aug 18, 2026
e7f6d01
fix(a11y): preserve enabled score action tooltips
seonghobae Aug 18, 2026
a3215b5
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 18, 2026
5ef9e09
test(a11y): restore described disabled score navigation
seonghobae Aug 18, 2026
4d45755
fix(a11y): restore described score page boundaries
seonghobae Aug 18, 2026
09b92d1
test(a11y): retain enabled score open tooltip
seonghobae Aug 18, 2026
ba0e364
fix(a11y): restore enabled score open tooltip
seonghobae Aug 18, 2026
65cd30d
fix(a11y): verify aria-disabled tests correctly checking title logic
seonghobae Aug 18, 2026
a3728f0
test(a11y): restore disabled pagination description regression
seonghobae Aug 19, 2026
69236f4
fix(a11y): restore disabled pagination descriptions
seonghobae Aug 19, 2026
c958aff
docs(a11y): restore disabled navigation rationale
seonghobae Aug 19, 2026
8388b9c
test(a11y): strengthen unavailable score action oracle
seonghobae Aug 19, 2026
4319fe9
test(score): redact untrusted bridge error details
seonghobae Aug 21, 2026
0ea4a70
fix(a11y): prevent score view error details from leaking paths or sec…
seonghobae Aug 21, 2026
0ce9f36
test(a11y): cover unavailable add-score action
seonghobae Aug 22, 2026
6076f22
fix(a11y): keep unavailable add-score action focusable
seonghobae Aug 22, 2026
1cb9109
test(a11y): verify focusable guarded attach action
seonghobae Aug 22, 2026
5d42307
docs(a11y): document focusable score action boundary
seonghobae Aug 22, 2026
5ea5663
docs(changelog): record focusable add-score guard
seonghobae Aug 22, 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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Changed

- Keep unavailable Add/Open/Remove score actions and PDF pagination controls keyboard-focusable, expose their unavailable state and recovery copy to assistive technology, and prevent project-missing, pagination-boundary, or repeated in-flight attach activation at the action boundary.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down Expand Up @@ -65,4 +69,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { RehearsalSong } from "@bandscope/shared-types";
import { invoke } from "@tauri-apps/api/core";
import { ScoreView } from "./ScoreView";

vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn()
}));

vi.mock("./ScoreViewer", () => ({
ScoreViewer: () => <div data-testid="score-viewer" />
}));

vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
scoreListTitle: "Attached scores",
scoreAttach: "Add score",
scoreRemove: "Remove",
scoreOpen: "Open score",
scoreRequiresProject: "Scores attach to the active analysis project.",
scoreNavDisabledHint: "Analyze or open a song first"
})[key] ?? key,
detectPreferredLocale: () => "en"
}));

const song = {
id: "song-1",
title: "Late Night Set",
scoreAttachments: [{ id: "score-1", fileName: "opener.pdf" }]
} as RehearsalSong;

describe("ScoreView unavailable action accessibility", () => {
it("links focusable unavailable actions to localized recovery copy and blocks activation", () => {
render(<ScoreView song={song} projectId={null} onSongUpdate={vi.fn()} />);

const requirement = screen.getByText("Scores attach to the active analysis project.");
const addButton = screen.getByRole("button", { name: "Add score" });
const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" });
const removeButton = screen.getByRole("button", { name: "Remove: opener.pdf" });

for (const button of [addButton, openButton, removeButton]) {
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute("aria-describedby", requirement.id);
expect(button).toHaveAttribute("title", "Analyze or open a song first");
expect(button).not.toBeDisabled();
}

fireEvent.click(addButton);
fireEvent.click(openButton);
fireEvent.click(removeButton);
expect(vi.mocked(invoke)).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { RehearsalSong } from "@bandscope/shared-types";
import { ScoreView } from "./ScoreView";

vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn()
}));

vi.mock("./ScoreViewer", () => ({
ScoreViewer: () => <div data-testid="score-viewer">Mock Viewer</div>
}));

vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreOpen: "Open score",
scoreRemove: "Remove"
})[key] ?? key,
detectPreferredLocale: () => "en"
}));

describe("ScoreView enabled action tooltips", () => {
it("exposes localized pointer tooltips for enabled open and remove actions", () => {
const song = {
id: "song-1",
title: "Test",
scoreAttachments: [{ id: "doc1", fileName: "opener.pdf" }]
} as RehearsalSong;

render(<ScoreView song={song} projectId="project-1-2" onSongUpdate={vi.fn()} />);

expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toHaveAttribute("title", "Open score: opener.pdf");
expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toHaveAttribute(
"title",
"Remove: opener.pdf"
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { beforeEach, expect, it, vi } from "vitest";

import { ScoreView } from "./ScoreView";
import { attachScorePdf } from "./scoreStorage";

vi.mock("./scoreStorage", () => ({
attachScorePdf: vi.fn(),
readScorePdf: vi.fn(),
removeScorePdf: vi.fn()
}));

vi.mock("./ScoreViewer", () => ({
ScoreViewer: () => <div data-testid="score-viewer" />
}));

vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
scoreListTitle: "Attached scores",
scoreListEmpty: "No scores attached to this song yet.",
scoreAttach: "Add score",
scoreAttaching: "Attaching...",
scoreRemove: "Remove",
scoreRemoveConfirm: "Remove {fileName} from this song?",
scoreOpen: "Open score",
scoreOpening: "Opening score PDF...",
scoreAttachFailed: "Could not attach the score PDF.",
scoreReadFailed: "Could not open the score PDF.",
scoreRemoveFailed: "Could not remove the score PDF.",
scoreRequiresProject: "Scores attach to the active analysis project.",
scoreNavDisabledHint: "Open an active project first."
})[key] ?? key,
detectPreferredLocale: () => "en"
}));

const mockAttachScorePdf = vi.mocked(attachScorePdf);

function makeSong(): RehearsalSong {
return {
id: "song-1",
title: "Late Night Set",
sections: [],
exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }
} as RehearsalSong;
}

beforeEach(() => {
mockAttachScorePdf.mockReset();
});

it("does not render dependency-controlled score bridge secrets or local paths", async () => {
mockAttachScorePdf.mockRejectedValueOnce(
new Error("Failed to open /Users/Alice/private-score.pdf token=super-secret")
);

render(<ScoreView song={makeSong()} projectId="project-1-2" onSongUpdate={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Add score" }));

const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Could not attach the score PDF.");
expect(alert).not.toHaveTextContent("/Users/Alice");
expect(alert).not.toHaveTextContent("private-score.pdf");
expect(alert).not.toHaveTextContent("token=super-secret");
});
69 changes: 63 additions & 6 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types";
import { invoke } from "@tauri-apps/api/core";
Expand Down Expand Up @@ -90,17 +90,74 @@ describe("ScoreView", () => {
expect(mockInvoke).not.toHaveBeenCalled();
});

it("disables score storage actions when no project workspace is active", () => {
it("keeps unavailable score storage actions focusable when no project workspace is active", () => {
const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]);
render(<ScoreView song={song} projectId={null} onSongUpdate={vi.fn()} />);

expect(screen.getByText("Scores attach to the active analysis project.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled();

fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
const addBtn = screen.getByRole("button", { name: "Add score" });
expect(addBtn).toHaveAttribute("aria-disabled", "true");
expect(addBtn).toHaveAttribute("aria-describedby");
expect(addBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60");
expect(addBtn).toHaveAttribute("title", "scoreNavDisabledHint");
expect(addBtn).not.toBeDisabled();

const openBtn = screen.getByRole("button", { name: "Open score: opener.pdf" });
expect(openBtn).toHaveAttribute("aria-disabled", "true");
expect(openBtn).toHaveAttribute("aria-describedby");
expect(openBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60");
expect(openBtn).toHaveAttribute("title", "scoreNavDisabledHint");

const removeBtn = screen.getByRole("button", { name: "Remove: opener.pdf" });
expect(removeBtn).toHaveAttribute("aria-disabled", "true");
expect(removeBtn).toHaveAttribute("aria-describedby");
expect(removeBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60");
expect(removeBtn).toHaveAttribute("title", "scoreNavDisabledHint");

const addClickEvent = createEvent.click(addBtn);
fireEvent(addBtn, addClickEvent);
expect(addClickEvent.defaultPrevented).toBe(true);
expect(mockInvoke).not.toHaveBeenCalled();

const openClickEvent = createEvent.click(openBtn);
fireEvent(openBtn, openClickEvent);
expect(openClickEvent.defaultPrevented).toBe(true);
expect(mockInvoke).not.toHaveBeenCalled();

const clickEvent = createEvent.click(removeBtn);
fireEvent(removeBtn, clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
});

it("blocks repeated attach activation while an attach is already pending", async () => {
let resolveAttach!: (value: unknown) => void;
mockInvoke
.mockImplementationOnce(() => new Promise((resolve) => { resolveAttach = resolve; }))
.mockResolvedValueOnce([1, 2, 3]);
const onSongUpdate = vi.fn();

render(<ScoreView song={makeSong()} projectId="project-1-2" onSongUpdate={onSongUpdate} />);

const addBtn = screen.getByRole("button", { name: "Add score" });
fireEvent.click(addBtn);

await waitFor(() => {
expect(addBtn).toHaveAttribute("aria-disabled", "true");
});

const repeatedClick = createEvent.click(addBtn);
fireEvent(addBtn, repeatedClick);
expect(repeatedClick.defaultPrevented).toBe(true);
expect(mockInvoke).toHaveBeenCalledTimes(1);

resolveAttach(attachResponse());

await waitFor(() => {
expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf");
});
expect(mockInvoke).toHaveBeenCalledTimes(2);
expect(onSongUpdate).toHaveBeenCalledTimes(1);
});

it("attaches a score, persists the metadata, and opens the new PDF", async () => {
Expand Down
63 changes: 49 additions & 14 deletions apps/desktop/src/features/score/ScoreView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useId, useMemo, useRef, useState } from "react";
import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react";
import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types";
import { createTranslator, detectPreferredLocale } from "../../i18n";
Expand Down Expand Up @@ -29,7 +29,14 @@ export interface ScoreViewProps {
function bridgeErrorDetail(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : null;
const firstLine = raw?.split(/\r?\n/)[0]?.trim();
return firstLine ? firstLine : fallback;
if (!firstLine) return fallback;

// Protect against dependency information leakage (paths and secrets)
if (firstLine.includes("/") || firstLine.includes("\\") || firstLine.toLowerCase().includes("token=")) {
return fallback;
}

return firstLine;
}

/**
Expand All @@ -39,6 +46,7 @@ function bridgeErrorDetail(error: unknown, fallback: string): string {
*/
export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const scoreRequiresProjectId = useId();
const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]);
const [selected, setSelected] = useState<ScoreAttachment | null>(null);
const [pdfBytes, setPdfBytes] = useState<Uint8Array | null>(null);
Expand Down Expand Up @@ -78,8 +86,8 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {

/**
* Attach a new score PDF via the native picker and open it. The attach
* control is disabled while `isAttaching`, so overlapping attaches cannot be
* started; the active project id is supplied by the enabled control.
* control is action-guarded while `isAttaching`, so overlapping attaches
* cannot be started; the active project id is supplied by the enabled control.
*/
const handleAttach = async (activeProjectId: string) => {
setError(null);
Expand Down Expand Up @@ -134,10 +142,18 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
<p className="mt-1 max-w-2xl text-sm text-slate-400">{t("scoreViewSubtitle")}</p>
</div>
<Button
onClick={projectId ? () => void handleAttach(projectId) : undefined}
disabled={!projectId || isAttaching}
onClick={(e) => {
if (!projectId || isAttaching) {
e.preventDefault();
} else {
void handleAttach(projectId);
}
}}
aria-disabled={!projectId || isAttaching}
aria-describedby={!projectId ? scoreRequiresProjectId : undefined}
title={!projectId ? t("scoreNavDisabledHint") : undefined}
variant="secondary"
className="min-h-11 border border-cyan-300/20 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20"
className="min-h-11 border border-cyan-300/20 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 aria-disabled:cursor-not-allowed aria-disabled:opacity-60"
>
{isAttaching ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Expand All @@ -149,7 +165,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
</div>

{!projectId && (
<p className="rounded-xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-medium text-amber-100">
<p
id={scoreRequiresProjectId}
className="rounded-xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-medium text-amber-100"
>
{t("scoreRequiresProject")}
</p>
)}
Expand Down Expand Up @@ -183,22 +202,38 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
>
<button
type="button"
onClick={projectId ? () => void openAttachment(projectId, attachment) : undefined}
disabled={!projectId}
onClick={(e) => {
if (!projectId) {
e.preventDefault();
} else {
void openAttachment(projectId, attachment);
}
}}
aria-disabled={!projectId}
aria-describedby={!projectId ? scoreRequiresProjectId : undefined}
aria-current={selected?.id === attachment.id ? "true" : undefined}
aria-label={`${t("scoreOpen")}: ${attachment.fileName}`}
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 text-left text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 disabled:cursor-not-allowed disabled:opacity-60"
title={!projectId ? t("scoreNavDisabledHint") : `${t("scoreOpen")}: ${attachment.fileName}`}
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 text-left text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-60"
>
<FileMusic className="size-4 shrink-0 text-cyan-300" aria-hidden="true" />
<span className="truncate">{attachment.fileName}</span>
</button>
<Button
variant="outline"
size="icon"
onClick={projectId ? () => void handleRemove(projectId, attachment) : undefined}
disabled={!projectId}
onClick={(e) => {
if (!projectId) {
e.preventDefault();
} else {
void handleRemove(projectId, attachment);
}
}}
aria-disabled={!projectId}
aria-describedby={!projectId ? scoreRequiresProjectId : undefined}
aria-label={`${t("scoreRemove")}: ${attachment.fileName}`}
className="size-10 border-rose-300/25 text-rose-200 hover:bg-rose-400/10"
title={!projectId ? t("scoreNavDisabledHint") : `${t("scoreRemove")}: ${attachment.fileName}`}
className="size-10 border-rose-300/25 text-rose-200 hover:bg-rose-400/10 aria-disabled:cursor-not-allowed aria-disabled:opacity-60"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
Expand Down
Loading
Loading