diff --git a/CHANGELOG.md b/CHANGELOG.md
index eea696893..7a4b11855 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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`).
\ No newline at end of file
diff --git a/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx b/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx
new file mode 100644
index 000000000..dbe9250f5
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.disabled-action-accessibility.test.tsx
@@ -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: () =>
+}));
+
+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();
+
+ 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();
+ });
+});
diff --git a/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx b/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx
new file mode 100644
index 000000000..e7a32dd43
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.enabled-action-tooltips.test.tsx
@@ -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: () =>
Mock Viewer
+}));
+
+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();
+
+ 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"
+ );
+ });
+});
diff --git a/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx b/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx
new file mode 100644
index 000000000..5e37ec7d9
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.error-privacy.test.tsx
@@ -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: () =>
+}));
+
+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();
+ 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");
+});
diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx
index de4ccb95c..c66a2bdc2 100644
--- a/apps/desktop/src/features/score/ScoreView.test.tsx
+++ b/apps/desktop/src/features/score/ScoreView.test.tsx
@@ -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";
@@ -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();
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();
+
+ 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 () => {
diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx
index 72732450f..c3d857e2b 100644
--- a/apps/desktop/src/features/score/ScoreView.tsx
+++ b/apps/desktop/src/features/score/ScoreView.tsx
@@ -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";
@@ -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;
}
/**
@@ -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(null);
const [pdfBytes, setPdfBytes] = useState(null);
@@ -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);
@@ -134,10 +142,18 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {