diff --git a/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx b/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx
new file mode 100644
index 00000000..b960456c
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx
@@ -0,0 +1,69 @@
+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: () =>
+}));
+
+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-a11y",
+ title: "Accessible Set",
+ sections: [],
+ exportSummary: { format: "cue-sheet", headline: "", focusSections: [] },
+ scoreAttachments: [
+ {
+ id: "score-a11y",
+ fileName: "opener.pdf"
+ }
+ ]
+} as RehearsalSong;
+
+describe("ScoreView disabled-score accessibility", () => {
+ it("references the visible disabled reason from each unavailable score button", () => {
+ render();
+
+ const reason = screen.getByText("Scores attach to the active analysis project.");
+ const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" });
+ const descriptionId = openButton.getAttribute("aria-describedby");
+
+ expect(openButton).toHaveAttribute("aria-disabled", "true");
+ expect(openButton).toHaveAttribute("title", "Analyze or open a song first");
+ expect(descriptionId).toBe(reason.id);
+ expect(descriptionId).not.toBe("");
+ expect(document.getElementById(descriptionId ?? "")).toBe(reason);
+ });
+
+ it("removes disabled-only semantics when a project workspace is available", () => {
+ render();
+
+ const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" });
+
+ expect(openButton).not.toHaveAttribute("aria-disabled");
+ expect(openButton).not.toHaveAttribute("aria-describedby");
+ expect(openButton).not.toHaveAttribute("title");
+ expect(
+ screen.queryByText("Scores attach to the active analysis project.")
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx
index de4ccb95..4c432e7f 100644
--- a/apps/desktop/src/features/score/ScoreView.test.tsx
+++ b/apps/desktop/src/features/score/ScoreView.test.tsx
@@ -96,7 +96,7 @@ describe("ScoreView", () => {
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: "Open score: opener.pdf" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
@@ -180,6 +180,16 @@ describe("ScoreView", () => {
});
});
+ it("renders disabled score open buttons with aria-disabled and a tooltip when there is no projectId", () => {
+ const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]);
+ render();
+
+ const button = screen.getByRole("button", { name: "Open score: opener.pdf" });
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ expect(button).toHaveAttribute("title", "scoreNavDisabledHint");
+ expect(button).not.toBeDisabled();
+ });
+
it("accepts Uint8Array read responses from the bridge", async () => {
mockInvoke.mockResolvedValueOnce(new Uint8Array([7, 7]));
const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]);
diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx
index 72732450..a5605afb 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";
@@ -39,6 +39,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);
@@ -149,7 +150,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
{!projectId && (
-
+
{t("scoreRequiresProject")}
)}
@@ -184,10 +188,12 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {