From 5ee3736724235c7e0a882e2de8822be97f71381d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 10:39:28 +0900 Subject: [PATCH 01/31] =?UTF-8?q?fix(ux):=20customer-facing=20copy=20audit?= =?UTF-8?q?=20=E2=80=94=20hide=20implementation=20boundaries,=20add=20next?= =?UTF-8?q?-action=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove internal-boundary jargon from user-visible strings (bridge, transcription/separation pipeline wording, raw enum values in tooltips and metric cards). - Give every error and pending state a next action (retry / re-pick file / check link), keeping regex-compatible prefixes for existing flows. - Localize previously hardcoded English UI strings in Workspace stem player, export buttons, song timeline summary, and GrooveMap states; add matching keys to ko/en locales (key sets stay in sync). - Display-only change: no behavior, permission, IPC, or storage changes. Verified: tsc --noEmit clean; vitest 196/196 with 100% statements, branches, functions, and lines coverage. --- apps/desktop/src/App.test.tsx | 4 +- apps/desktop/src/App.tsx | 7 ++- .../src/features/score/ScoreView.test.tsx | 6 +- .../src/features/score/scoreStorage.ts | 2 +- .../features/workspace/ConfidenceBadge.tsx | 2 +- .../src/features/workspace/GrooveMap.tsx | 16 ++--- .../src/features/workspace/Workspace.test.tsx | 6 +- .../src/features/workspace/Workspace.tsx | 44 +++++++------- apps/desktop/src/i18n/index.test.ts | 4 +- apps/desktop/src/lib/analysis.test.ts | 18 +++--- apps/desktop/src/lib/analysis.ts | 15 ++--- apps/desktop/src/locales/en/common.json | 59 +++++++++++++------ apps/desktop/src/locales/ko/common.json | 59 +++++++++++++------ 13 files changed, 150 insertions(+), 92 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..0c77eef94 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -299,7 +299,7 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); - expect(screen.getByText(/Stems/i)).toBeTruthy(); + expect(screen.getByText("Stems")).toBeTruthy(); expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); }); @@ -644,7 +644,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toMatch(/analysis could not start/i); }); - expect(screen.getAllByRole("status").some((status) => /analysis failed during execution/i.test(status.textContent ?? ""))).toBe(true); + expect(screen.getAllByRole("status").some((status) => /stopped partway through/i.test(status.textContent ?? ""))).toBe(true); }); it("holds a terminal progress value immediately for pushed failed statuses", async () => { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..e30d8a5eb 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -210,6 +210,11 @@ function sectionCountDetail(t: ReturnType, sectionCount function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnType }) { const sectionCount = song?.sections.length ?? 0; const confidenceOrder = { high: 3, medium: 2, low: 1 } as const; + const confidenceShortKeys = { + low: "confidenceShortLow", + medium: "confidenceShortMedium", + high: "confidenceShortHigh" + } as const satisfies Record; // Performance: Avoid O(N) array scan with .reduce() to find minimum confidence. // Instead use a for loop that can early exit (O(K)) as soon as the lowest bound ("low") is hit. @@ -224,7 +229,7 @@ function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnTy } } } - const confidence = lowestConfidence ? `${lowestConfidence[0].toUpperCase()}${lowestConfidence.slice(1)}` : t("metricConfidenceReady"); + const confidence = lowestConfidence ? t(confidenceShortKeys[lowestConfidence]) : t("metricConfidenceReady"); const detail = sectionCountDetail(t, sectionCount); return ( diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..2b4595cae 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -153,7 +153,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Add score" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again."); }); it("opens an existing attachment through the read command", async () => { @@ -216,7 +216,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); expect(await screen.findByRole("alert")).toHaveTextContent( - "Could not open the score PDF. Invalid score bridge response" + "Could not open the score PDF. The score could not be prepared. Try adding it again." ); }); @@ -289,7 +289,7 @@ describe("ScoreView", () => { fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" })); - expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response"); + expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again."); }); it("fails closed when no desktop bridge is available", async () => { diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f12591..3a2fcc60e 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -15,7 +15,7 @@ type TauriBridgeWindow = Window & { export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; -const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +const INVALID_RESPONSE_MESSAGE = "The score could not be prepared. Try adding it again."; /** * Resolve the desktop invoke bridge following the same detection rules as diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx index f3da0b8e0..7561c5425 100644 --- a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx @@ -32,7 +32,7 @@ export function ConfidenceBadge({ level }: ConfidenceBadgeProps) { {label} diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx index 2745d4d79..7300bc683 100644 --- a/apps/desktop/src/features/workspace/GrooveMap.tsx +++ b/apps/desktop/src/features/workspace/GrooveMap.tsx @@ -1,5 +1,6 @@ import { memo, useMemo } from "react"; import type { TranscriptionNote } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; @@ -13,6 +14,7 @@ interface GrooveMapProps { /** Documented. */ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); const renderedNotes = notes ?? EMPTY_NOTES; // Find max offset to determine timeline width @@ -44,10 +46,10 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { > - ); @@ -58,7 +60,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
- No bass line transcription yet. Use it when you want to check the groove before rehearsal. + {t("grooveEmptyHint")}
); } @@ -68,13 +70,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) { className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" role="region" tabIndex={0} - aria-label="Bass transcription groove map" + aria-label={t("grooveMapAriaLabel")} >
- Transcription complete. {renderedNotes.length} notes analyzed. + {t("grooveCompleteSrLabel").replace("{count}", String(renderedNotes.length))}

- {renderedNotes.length} notes mapped for rehearsal + {t("grooveNotesMappedLabel").replace("{count}", String(renderedNotes.length))}

diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..6c5c7f5c6 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -96,9 +96,9 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement; + const transcribeButton = screen.getByRole("button", { name: "Map bass notes" }) as HTMLButtonElement; expect(transcribeButton.disabled).toBe(false); - expect(transcribeButton.title).toBe("Transcribe part"); + expect(transcribeButton.title).toBe("Show this part's bass notes on the groove map."); }); it("renders bass transcription in the dark rehearsal cockpit system", () => { @@ -115,7 +115,7 @@ describe("Workspace", () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i }); + const grooveMap = screen.getByRole("region", { name: /bass groove map/i }); expect(grooveMap.className).toContain("bg-slate-950"); expect(screen.getByText("E2")).toBeTruthy(); expect(screen.getByText("G2")).toBeTruthy(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..f08347c82 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -83,7 +83,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R role="region" tabIndex={0} className="overflow-x-auto rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(8,18,35,0.96),rgba(2,6,23,0.98))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" - aria-label="Scrollable song structure timeline" + aria-label={t("songStructureTimelineAriaLabel")} >
@@ -292,7 +292,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceSongTimelineLabel")}

- {song.sections.length} section{song.sections.length === 1 ? "" : "s"} mapped with groove, role cues, and chord confidence notes. + {t("songTimelineSummary").replace("{count}", String(song.sections.length))}

@@ -320,13 +320,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceStemsLabel")}

-

Stem lanes will appear when separation results are available.

+

{t("stemsEmptyHint")}

{t("workspaceRehearsalPrioritiesLabel")}

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. + {t("rehearsalFocusPrefix")} {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || t("rehearsalFocusFallback")}.

@@ -337,7 +337,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRolesHarmonyLabel")}

-

Filter the board by player or vocal role without losing the full form context.

+

{t("rolesHarmonyHint")}

-

Stem Player

+

{t("stemPlayerTitle")}

{activeRoleDetails?.name ?? activeRole}

{canTranscribeBass ? ( ) : ( )}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..9ee10589a 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -59,7 +59,7 @@ describe("i18n", () => { it("translates to Korean explicitly", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); - expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("appSubtitle")).toBe("합주 준비를 도와주는 밴드 메이트"); }); it("falls back to English when a Korean translation is missing", () => { @@ -69,7 +69,7 @@ describe("i18n", () => { delete koDictionary.appSubtitle; try { - expect(t("appSubtitle")).toBe("Local-first desktop analysis tool for rehearsal prep"); + expect(t("appSubtitle")).toBe("Your band mate for rehearsal prep"); } finally { koDictionary.appSubtitle = originalSubtitle; } diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..91cfc7824 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -56,7 +56,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -71,7 +71,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -130,14 +130,14 @@ describe("analysis bridge", () => { const running = await getAnalysisJobStatus(queued.jobId); expect(running).toMatchObject({ state: "running", - progressLabel: "Decoding audio", + progressLabel: "Reading your track", progressStage: "decode", progressPercent: 20 }); expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ state: "running", - progressLabel: "Separating stems... (45%)", + progressLabel: "Separating stems", progressStage: "separate", progressPercent: 45 }); @@ -149,7 +149,7 @@ describe("analysis bridge", () => { }); expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ state: "running", - progressLabel: "Saving reusable features", + progressLabel: "Preparing results for next time", progressStage: "persist", progressPercent: 90 }); @@ -181,7 +181,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -196,7 +196,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -213,7 +213,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); @@ -233,7 +233,7 @@ describe("analysis bridge", () => { ok: false, error: { code: "invalid_request", - message: "Only standard YouTube URLs are supported." + message: "Use a standard YouTube video link (youtube.com/watch or youtu.be)." } }); }); diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..b853f5436 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -29,12 +29,13 @@ declare global { const browserJobStore = new Map(); const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 }, + { progressLabel: "Reading your track", progressStage: "decode", progressPercent: 20 }, + { progressLabel: "Separating stems", progressStage: "separate", progressPercent: 45 }, { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } + { progressLabel: "Preparing results for next time", progressStage: "persist", progressPercent: 90 } ] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; +const YOUTUBE_LINK_GUIDANCE_MESSAGE = "Use a standard YouTube video link (youtube.com/watch or youtu.be)."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", @@ -182,7 +183,7 @@ async function browserFallback(command: string, args?: Record): if (command === "import_youtube_url") { if (!isSupportedYoutubeUrl(args?.url)) { - throw new Error("Only standard YouTube URLs are supported."); + throw new Error(YOUTUBE_LINK_GUIDANCE_MESSAGE); } const projectId = "browser-youtube-project"; @@ -201,10 +202,10 @@ async function browserFallback(command: string, args?: Record): } if (command === "load_project") { - throw new Error("Local load not supported in browser"); + throw new Error("Projects open in the BandScope desktop app."); } - throw new Error(`Unknown analysis bridge command: ${command}`); + throw new Error("This action is not available right now."); } /** Documented. */ @@ -319,7 +320,7 @@ export async function importYoutubeUrl(url: string): Promise Date: Tue, 25 Aug 2026 20:04:30 -0700 Subject: [PATCH 02/31] test(workspace): cover singular timeline summary --- .../Workspace.timeline-summary.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx new file mode 100644 index 000000000..b19656f5b --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace song timeline summary", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("uses singular English copy for a one-section song", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = song.sections.slice(0, 1); + + render(); + + expect( + screen.getByText("1 section mapped with groove, role cues, and chord confidence notes.") + ).toBeTruthy(); + }); +}); From 3ef661c138a75aebdd35a68fd539eb1f0a8d3f57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:05:56 -0700 Subject: [PATCH 03/31] test(workspace): specify cardinal-safe timeline copy --- .../features/workspace/Workspace.timeline-summary.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx index b19656f5b..92c5fde84 100644 --- a/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.timeline-summary.test.tsx @@ -17,7 +17,7 @@ describe("Workspace song timeline summary", () => { setNavigatorLanguage(originalLanguage); }); - it("uses singular English copy for a one-section song", () => { + it("uses cardinal-safe English copy for a one-section song", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); song.sections = song.sections.slice(0, 1); @@ -25,7 +25,9 @@ describe("Workspace song timeline summary", () => { render(); expect( - screen.getByText("1 section mapped with groove, role cues, and chord confidence notes.") + screen.getByText( + "Sections mapped: 1. Use the groove, role cues, and chord confidence notes to plan the first pass." + ) ).toBeTruthy(); }); }); From 638ad787ed90ff4e88c72c336409e4a80c1546c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:06:31 -0700 Subject: [PATCH 04/31] fix(workspace): make timeline count grammar-safe --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 0a71bf521..a94bfc80d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -152,7 +152,7 @@ "exportCueSheetButton": "Export Cue Sheet (CSV)", "exportChartButton": "Export Chart (JSON)", "exportHandoffButton": "Export Handoff (JSON)", - "songTimelineSummary": "{count} sections mapped with groove, role cues, and chord confidence notes.", + "songTimelineSummary": "Sections mapped: {count}. Use the groove, role cues, and chord confidence notes to plan the first pass.", "stemsEmptyHint": "Part audio (stems) will show up here as soon as it's ready — play it on repeat while practicing.", "rolesHarmonyHint": "Filter the board by player or vocal role without losing the full form context.", "stemPlayerTitle": "Stem Player", From 010e74716d476f18128445699d023906e0cda549 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:11:06 -0700 Subject: [PATCH 05/31] test(i18n): expose localized source error regression --- .../src/App.localized-source-error.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/desktop/src/App.localized-source-error.test.tsx diff --git a/apps/desktop/src/App.localized-source-error.test.tsx b/apps/desktop/src/App.localized-source-error.test.tsx new file mode 100644 index 000000000..1531ec9b4 --- /dev/null +++ b/apps/desktop/src/App.localized-source-error.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { App } from "./App"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("App localized source errors", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("keeps the browser local-audio fallback in the selected Korean locale", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + render(); + fireEvent.click(screen.getByRole("button", { name: "로컬 오디오 선택" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요." + ); + expect(screen.queryByText("Choose a WAV, MP3, FLAC, or M4A file to start analysis.")).toBeNull(); + }); +}); From 716035f6a9fc8cc6047fcac9fa4c737d9e171c2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:12:17 -0700 Subject: [PATCH 06/31] test(i18n): cover localized analysis fallbacks --- .../src/lib/analysis.localized-errors.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.localized-errors.test.ts diff --git a/apps/desktop/src/lib/analysis.localized-errors.test.ts b/apps/desktop/src/lib/analysis.localized-errors.test.ts new file mode 100644 index 000000000..db0065e45 --- /dev/null +++ b/apps/desktop/src/lib/analysis.localized-errors.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { importYoutubeUrl, loadProject } from "./analysis"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("analysis buyer-visible fallback localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("returns Korean guidance for an invalid YouTube URL", async () => { + setNavigatorLanguage("ko-KR"); + const result = await importYoutubeUrl("https://example.com/not-youtube"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "표준 유튜브 영상 링크(youtube.com/watch 또는 youtu.be)를 사용해 주세요." + ); + } + }); + + it("keeps the browser project fallback in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + await expect(loadProject()).rejects.toThrow( + "프로젝트는 BandScope 데스크톱 앱에서 열어 주세요." + ); + }); +}); From 375e41f79461fe3f4844acf5526858d73180c38e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:12:25 -0700 Subject: [PATCH 07/31] test(i18n): cover localized score errors --- .../scoreStorage.localized-errors.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts diff --git a/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts b/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts new file mode 100644 index 000000000..968699201 --- /dev/null +++ b/apps/desktop/src/features/score/scoreStorage.localized-errors.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { attachScorePdf } from "./scoreStorage"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("score storage buyer-visible error localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("keeps the browser-only score message in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + "악보 PDF는 BandScope 데스크톱 앱에서만 사용할 수 있습니다." + ); + }); + + it("keeps an invalid bridge response in Korean", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => ({}); + + await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow( + "악보를 준비할 수 없습니다. 다시 추가해 주세요." + ); + }); +}); From 13845c060cc443f3760a6ec7aafd6cbefc70827e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:13:31 -0700 Subject: [PATCH 08/31] feat(i18n): add localized bridge guidance --- apps/desktop/src/locales/en/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index a94bfc80d..676277d92 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -87,12 +87,15 @@ "scoreAttachFailed": "Could not attach the score PDF.", "scoreReadFailed": "Could not open the score PDF.", "scoreRemoveFailed": "Could not remove the score PDF.", + "scoreDesktopOnly": "Score PDFs are only available in the desktop app.", + "scoreInvalidResponse": "The score could not be prepared. Try adding it again.", "scoreRequiresProject": "Scores attach to the active analysis project. Analyze local audio or a YouTube import first.", "scoreNavDisabledHint": "Analyze or open a song first", "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", "youtubeImportFailed": "Failed to import YouTube URL. Check that it is a standard YouTube video link, then try again.", + "youtubeLinkGuidance": "Use a standard YouTube video link (youtube.com/watch or youtu.be).", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", @@ -143,6 +146,8 @@ "metricPriorityPendingDetail": "Pick a song to see what to practice first", "loadProjectFailedPrefix": "Failed to load project", "loadProjectFailedFallback": "The selected project could not be loaded. Pick the file again or try another project.", + "projectsDesktopOnly": "Projects open in the BandScope desktop app.", + "actionUnavailable": "This action is not available right now.", "saveProjectFailedPrefix": "Failed to save project", "saveProjectFailedFallback": "The project could not be saved. Try again in a moment.", "practiceProgressRegionLabel": "Practice Progress", From 48ce16f0d731fa460832c4c206a2a26254017756 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:14:08 -0700 Subject: [PATCH 09/31] feat(i18n): add Korean bridge guidance --- apps/desktop/src/locales/ko/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 6475c21f5..1e6deea60 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -87,12 +87,15 @@ "scoreAttachFailed": "악보 PDF를 첨부하지 못했습니다.", "scoreReadFailed": "악보 PDF를 열지 못했습니다.", "scoreRemoveFailed": "악보 PDF를 삭제하지 못했습니다.", + "scoreDesktopOnly": "악보 PDF는 BandScope 데스크톱 앱에서만 사용할 수 있습니다.", + "scoreInvalidResponse": "악보를 준비할 수 없습니다. 다시 추가해 주세요.", "scoreRequiresProject": "악보는 활성 분석 프로젝트에 첨부됩니다. 먼저 로컬 오디오나 유튜브 가져오기로 분석을 실행하세요.", "scoreNavDisabledHint": "먼저 곡을 분석하거나 프로젝트를 여세요", "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다. 표준 유튜브 영상 주소인지 확인한 뒤 다시 시도해 주세요.", + "youtubeLinkGuidance": "표준 유튜브 영상 링크(youtube.com/watch 또는 youtu.be)를 사용해 주세요.", "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", @@ -143,6 +146,8 @@ "metricPriorityPendingDetail": "곡을 고르면 먼저 연습할 구간을 알려드려요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", "loadProjectFailedFallback": "선택한 프로젝트를 불러올 수 없습니다. 파일을 다시 선택하거나 다른 프로젝트를 열어 주세요.", + "projectsDesktopOnly": "프로젝트는 BandScope 데스크톱 앱에서 열어 주세요.", + "actionUnavailable": "지금은 이 작업을 사용할 수 없습니다.", "saveProjectFailedPrefix": "프로젝트를 저장하지 못했습니다", "saveProjectFailedFallback": "프로젝트를 저장할 수 없습니다. 잠시 후 다시 시도해 주세요.", "practiceProgressRegionLabel": "연습 진척도", From 25065ca1b89bb4703695d92fd67b78dc4ffe0d31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:14:36 -0700 Subject: [PATCH 10/31] fix(i18n): localize score storage errors --- .../src/features/score/scoreStorage.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 3a2fcc60e..6c79cf431 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { ScoreAttachment } from "@bandscope/shared-types"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -14,8 +15,13 @@ type TauriBridgeWindow = Window & { */ export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; -const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; -const INVALID_RESPONSE_MESSAGE = "The score could not be prepared. Try adding it again."; +const BRIDGE_UNAVAILABLE_KEY = "scoreDesktopOnly" satisfies TranslationKey; +const INVALID_RESPONSE_KEY = "scoreInvalidResponse" satisfies TranslationKey; + +/** Resolve a buyer-visible score-storage message in the currently selected locale. */ +function scoreMessage(key: TranslationKey): string { + return createTranslator(detectPreferredLocale())(key); +} /** * Resolve the desktop invoke bridge following the same detection rules as @@ -41,12 +47,12 @@ function getInvoke(): TauriInvoke | null { /** * Invoke a score storage command on the desktop bridge, failing closed with - * a stable error when no bridge is available (browser preview builds). + * a stable localized error when no bridge is available (browser preview builds). */ async function invokeScoreCommand(command: string, args: Record): Promise { const invokeCommand = getInvoke(); if (!invokeCommand) { - throw new Error(BRIDGE_UNAVAILABLE_MESSAGE); + throw new Error(scoreMessage(BRIDGE_UNAVAILABLE_KEY)); } return invokeCommand(command, args); @@ -67,7 +73,7 @@ export async function attachScorePdf(projectId: string, songId: string): Promise typeof (response as Record).fileName !== "string" || typeof (response as Record).fileSizeBytes !== "number" ) { - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } const payload = response as { scoreId: string; fileName: string; fileSizeBytes: number }; @@ -95,7 +101,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< return Uint8Array.from(response as number[]); } - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } /** @@ -105,7 +111,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< export async function removeScorePdf(projectId: string, scoreId: string): Promise { const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId }); if (typeof response !== "boolean") { - throw new Error(INVALID_RESPONSE_MESSAGE); + throw new Error(scoreMessage(INVALID_RESPONSE_KEY)); } return response; From fcf6e2376dd9a40131fce5cdabefbad1e17df2ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:16:15 -0700 Subject: [PATCH 11/31] feat(i18n): localize browser analysis progress --- apps/desktop/src/locales/en/common.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 676277d92..9e1b0fda6 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -22,6 +22,10 @@ "analysisStateRunning": "Running analysis", "analysisStateSucceeded": "Analysis ready", "analysisStateFailed": "The analysis stopped partway through. Try running it again.", + "analysisProgressReadingTrack": "Reading your track", + "analysisProgressSeparatingStems": "Separating stems", + "analysisProgressBuildingCues": "Building rehearsal cues", + "analysisProgressPreparingResults": "Preparing results for next time", "confidenceLevelLow": "Low confidence", "confidenceLevelMedium": "Needs ear check", "confidenceLevelHigh": "Ready to trust", From 9cdbb7964169ba14fea918a4584daee697e85d51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:16:52 -0700 Subject: [PATCH 12/31] feat(i18n): localize Korean analysis progress --- apps/desktop/src/locales/ko/common.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 1e6deea60..4a06206aa 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -22,6 +22,10 @@ "analysisStateRunning": "분석 실행 중", "analysisStateSucceeded": "분석 준비 완료", "analysisStateFailed": "분석이 끝까지 진행되지 않았어요. 다시 실행해 주세요.", + "analysisProgressReadingTrack": "선택한 곡을 읽는 중", + "analysisProgressSeparatingStems": "파트 소리를 분리하는 중", + "analysisProgressBuildingCues": "합주 큐를 만드는 중", + "analysisProgressPreparingResults": "다음 합주를 위해 결과를 정리하는 중", "confidenceLevelLow": "확신이 낮음", "confidenceLevelMedium": "귀로 한 번 더 확인", "confidenceLevelHigh": "믿고 가져가도 됨", From f98f12c9bb8f0b18a4e072be53ce21f0ebdf64f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:17:50 -0700 Subject: [PATCH 13/31] fix(i18n): localize analysis bridge messages --- apps/desktop/src/lib/analysis.ts | 57 ++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index b853f5436..7d4e6e0b0 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -15,6 +15,7 @@ import { type RehearsalSong } from "@bandscope/shared-types"; import { listen } from "@tauri-apps/api/event"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../i18n"; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -29,13 +30,16 @@ declare global { const browserJobStore = new Map(); const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Reading your track", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems", progressStage: "separate", progressPercent: 45 }, - { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Preparing results for next time", progressStage: "persist", progressPercent: 90 } -] as const; + { progressLabelKey: "analysisProgressReadingTrack", progressStage: "decode", progressPercent: 20 }, + { progressLabelKey: "analysisProgressSeparatingStems", progressStage: "separate", progressPercent: 45 }, + { progressLabelKey: "analysisProgressBuildingCues", progressStage: "analyze", progressPercent: 70 }, + { progressLabelKey: "analysisProgressPreparingResults", progressStage: "persist", progressPercent: 90 } +] as const satisfies readonly { + progressLabelKey: TranslationKey; + progressStage: string; + progressPercent: number; +}[]; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; -const YOUTUBE_LINK_GUIDANCE_MESSAGE = "Use a standard YouTube video link (youtube.com/watch or youtu.be)."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "Could not read the selected audio file.", @@ -53,6 +57,21 @@ export type LocalAudioSelectionResult = | { ok: true; bootstrap: ProjectBootstrapSummary } | { ok: false; error: AnalysisJobError }; +/** Resolve a buyer-visible analysis message in the currently selected locale. */ +function analysisMessage(key: TranslationKey): string { + return createTranslator(detectPreferredLocale())(key); +} + +/** Map bridge-safe local-audio failures to localized customer guidance. */ +function localAudioSelectionMessage(error: unknown): string { + if (error instanceof Error && SAFE_LOCAL_AUDIO_MESSAGES.has(error.message)) { + return error.message === UNSUPPORTED_LOCAL_AUDIO_MESSAGE + ? analysisMessage("unsupportedLocalAudio") + : analysisMessage("analysisCouldNotStart"); + } + return analysisMessage("unsupportedLocalAudio"); +} + /** Documented. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { @@ -120,7 +139,7 @@ async function browserFallback(command: string, args?: Record): const queued = createAnalysisJobStatus({ jobId, state: "queued", - progressLabel: "Queued for analysis", + progressLabel: analysisMessage("analysisStateQueued"), progressStage: "queued", progressPercent: 0, cacheStatus: "disabled" @@ -142,7 +161,7 @@ async function browserFallback(command: string, args?: Record): state: "failed", error: { code: "not_found", - message: "Analysis job was not found." + message: analysisMessage("analysisStateFailed") } }); } @@ -154,7 +173,7 @@ async function browserFallback(command: string, args?: Record): jobId, state: "running", requestedAt: existing.requestedAt, - progressLabel: nextStep.progressLabel, + progressLabel: analysisMessage(nextStep.progressLabelKey), progressStage: nextStep.progressStage, progressPercent: nextStep.progressPercent, cacheStatus: "disabled" @@ -166,7 +185,7 @@ async function browserFallback(command: string, args?: Record): const succeeded = createAnalysisJobStatus({ jobId, state: "succeeded", - progressLabel: "Analysis ready", + progressLabel: analysisMessage("analysisStateSucceeded"), progressStage: "ready", progressPercent: 100, cacheStatus: "disabled", @@ -183,7 +202,7 @@ async function browserFallback(command: string, args?: Record): if (command === "import_youtube_url") { if (!isSupportedYoutubeUrl(args?.url)) { - throw new Error(YOUTUBE_LINK_GUIDANCE_MESSAGE); + throw new Error(analysisMessage("youtubeLinkGuidance")); } const projectId = "browser-youtube-project"; @@ -202,10 +221,10 @@ async function browserFallback(command: string, args?: Record): } if (command === "load_project") { - throw new Error("Projects open in the BandScope desktop app."); + throw new Error(analysisMessage("projectsDesktopOnly")); } - throw new Error("This action is not available right now."); + throw new Error(analysisMessage("actionUnavailable")); } /** Documented. */ @@ -236,10 +255,7 @@ export async function selectLocalAudioSource(): Promise Date: Tue, 25 Aug 2026 20:20:44 -0700 Subject: [PATCH 14/31] test(i18n): cover localized safe-error branches --- .../src/lib/analysis.localized-errors.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.localized-errors.test.ts b/apps/desktop/src/lib/analysis.localized-errors.test.ts index db0065e45..3816e6758 100644 --- a/apps/desktop/src/lib/analysis.localized-errors.test.ts +++ b/apps/desktop/src/lib/analysis.localized-errors.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { importYoutubeUrl, loadProject } from "./analysis"; +import { importYoutubeUrl, loadProject, selectLocalAudioSource } from "./analysis"; const originalLanguage = navigator.language; const originalInternals = window.__TAURI_INTERNALS__; @@ -31,6 +31,41 @@ describe("analysis buyer-visible fallback localization", () => { } }); + it("maps allowlisted local preparation errors to Korean next-action guidance", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("Could not read the selected audio file."); + }; + + const result = await selectLocalAudioSource(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "분석을 시작할 수 없습니다. 선택한 오디오나 유튜브 링크를 확인한 뒤 다시 시도해 주세요." + ); + } + }); + + it("does not surface unknown local bridge errors in Korean UI", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("sensitive implementation detail"); + }; + + const result = await selectLocalAudioSource(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요." + ); + expect(result.error.message).not.toContain("sensitive implementation detail"); + } + }); + it("keeps the browser project fallback in Korean", async () => { setNavigatorLanguage("ko-KR"); window.__TAURI_INTERNALS__ = undefined; From f8b0fef474f6d74c51926d9252642b72dc65fbb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:23:47 -0700 Subject: [PATCH 15/31] test(i18n): preserve distinct local failure guidance --- .../src/lib/analysis.localized-errors.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/analysis.localized-errors.test.ts b/apps/desktop/src/lib/analysis.localized-errors.test.ts index 3816e6758..cf02ae7d6 100644 --- a/apps/desktop/src/lib/analysis.localized-errors.test.ts +++ b/apps/desktop/src/lib/analysis.localized-errors.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; -import { importYoutubeUrl, loadProject, selectLocalAudioSource } from "./analysis"; +import { + getAnalysisJobStatus, + importYoutubeUrl, + loadProject, + selectLocalAudioSource +} from "./analysis"; const originalLanguage = navigator.language; const originalInternals = window.__TAURI_INTERNALS__; @@ -31,20 +36,23 @@ describe("analysis buyer-visible fallback localization", () => { } }); - it("maps allowlisted local preparation errors to Korean next-action guidance", async () => { + it.each([ + ["Could not read the selected audio file.", "선택한 오디오 파일을 읽을 수 없습니다. 파일을 다시 선택해 주세요."], + ["Could not prepare the local project workspace.", "프로젝트 작업 공간을 준비할 수 없습니다. 저장 위치를 확인한 뒤 다시 시도해 주세요."], + ["Could not prepare the local cache workspace.", "분석 캐시를 준비할 수 없습니다. 잠시 후 다시 시도해 주세요."], + ["Could not prepare the local temp workspace.", "분석 임시 공간을 준비할 수 없습니다. 잠시 후 다시 시도해 주세요."] + ])("preserves distinct Korean next actions for safe local failure %s", async (bridgeMessage, expected) => { setNavigatorLanguage("ko-KR"); window.__TAURI_INTERNALS__ = undefined; window.__TAURI_INVOKE__ = async () => { - throw new Error("Could not read the selected audio file."); + throw new Error(bridgeMessage); }; const result = await selectLocalAudioSource(); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.message).toBe( - "분석을 시작할 수 없습니다. 선택한 오디오나 유튜브 링크를 확인한 뒤 다시 시도해 주세요." - ); + expect(result.error.message).toBe(expected); } }); @@ -66,6 +74,20 @@ describe("analysis buyer-visible fallback localization", () => { } }); + it("describes an unknown browser analysis job as not found", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + const status = await getAnalysisJobStatus("missing-job"); + + expect(status.state).toBe("failed"); + expect(status.error?.code).toBe("not_found"); + expect(status.error?.message).toBe( + "해당 분석 작업을 찾을 수 없습니다. 분석을 다시 시작해 주세요." + ); + }); + it("keeps the browser project fallback in Korean", async () => { setNavigatorLanguage("ko-KR"); window.__TAURI_INTERNALS__ = undefined; From ff6d61f71111298326a8aa498e940963334491fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:25:41 -0700 Subject: [PATCH 16/31] feat(i18n): preserve specific analysis failure guidance --- apps/desktop/src/locales/en/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 9e1b0fda6..71e124824 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -18,10 +18,15 @@ "startAnalysis": "Start analysis", "startingAnalysis": "Starting...", "analysisCouldNotStart": "Analysis could not start. Check the selected audio or YouTube link, then try again.", + "localAudioReadFailed": "The selected audio file could not be read. Choose the file again.", + "localProjectWorkspaceFailed": "The project workspace could not be prepared. Check the save location, then try again.", + "localCacheWorkspaceFailed": "The analysis cache could not be prepared. Try again in a moment.", + "localTempWorkspaceFailed": "The analysis temporary workspace could not be prepared. Try again in a moment.", "analysisStateQueued": "Queued for analysis", "analysisStateRunning": "Running analysis", "analysisStateSucceeded": "Analysis ready", "analysisStateFailed": "The analysis stopped partway through. Try running it again.", + "analysisJobNotFound": "That analysis job could not be found. Start the analysis again.", "analysisProgressReadingTrack": "Reading your track", "analysisProgressSeparatingStems": "Separating stems", "analysisProgressBuildingCues": "Building rehearsal cues", From 41cca664d572573253166a10e88eb7fe3501693d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:26:14 -0700 Subject: [PATCH 17/31] feat(i18n): preserve Korean analysis failure guidance --- apps/desktop/src/locales/ko/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 4a06206aa..f26225115 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -18,10 +18,15 @@ "startAnalysis": "분석 시작", "startingAnalysis": "시작 중...", "analysisCouldNotStart": "분석을 시작할 수 없습니다. 선택한 오디오나 유튜브 링크를 확인한 뒤 다시 시도해 주세요.", + "localAudioReadFailed": "선택한 오디오 파일을 읽을 수 없습니다. 파일을 다시 선택해 주세요.", + "localProjectWorkspaceFailed": "프로젝트 작업 공간을 준비할 수 없습니다. 저장 위치를 확인한 뒤 다시 시도해 주세요.", + "localCacheWorkspaceFailed": "분석 캐시를 준비할 수 없습니다. 잠시 후 다시 시도해 주세요.", + "localTempWorkspaceFailed": "분석 임시 공간을 준비할 수 없습니다. 잠시 후 다시 시도해 주세요.", "analysisStateQueued": "분석 대기 중", "analysisStateRunning": "분석 실행 중", "analysisStateSucceeded": "분석 준비 완료", "analysisStateFailed": "분석이 끝까지 진행되지 않았어요. 다시 실행해 주세요.", + "analysisJobNotFound": "해당 분석 작업을 찾을 수 없습니다. 분석을 다시 시작해 주세요.", "analysisProgressReadingTrack": "선택한 곡을 읽는 중", "analysisProgressSeparatingStems": "파트 소리를 분리하는 중", "analysisProgressBuildingCues": "합주 큐를 만드는 중", From 537d2674a400c3d854102ad01606565c0cf022d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:27:01 -0700 Subject: [PATCH 18/31] fix(i18n): preserve specific safe analysis errors --- apps/desktop/src/lib/analysis.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index 7d4e6e0b0..e18a6bc42 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -40,12 +40,12 @@ const BROWSER_PROGRESS_STEPS = [ progressPercent: number; }[]; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; -const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ - UNSUPPORTED_LOCAL_AUDIO_MESSAGE, - "Could not read the selected audio file.", - "Could not prepare the local project workspace.", - "Could not prepare the local cache workspace.", - "Could not prepare the local temp workspace." +const SAFE_LOCAL_AUDIO_MESSAGE_KEYS = new Map([ + [UNSUPPORTED_LOCAL_AUDIO_MESSAGE, "unsupportedLocalAudio"], + ["Could not read the selected audio file.", "localAudioReadFailed"], + ["Could not prepare the local project workspace.", "localProjectWorkspaceFailed"], + ["Could not prepare the local cache workspace.", "localCacheWorkspaceFailed"], + ["Could not prepare the local temp workspace.", "localTempWorkspaceFailed"] ]); const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -62,12 +62,13 @@ function analysisMessage(key: TranslationKey): string { return createTranslator(detectPreferredLocale())(key); } -/** Map bridge-safe local-audio failures to localized customer guidance. */ +/** Map bridge-safe local-audio failures to localized customer guidance without exposing unknown details. */ function localAudioSelectionMessage(error: unknown): string { - if (error instanceof Error && SAFE_LOCAL_AUDIO_MESSAGES.has(error.message)) { - return error.message === UNSUPPORTED_LOCAL_AUDIO_MESSAGE - ? analysisMessage("unsupportedLocalAudio") - : analysisMessage("analysisCouldNotStart"); + if (error instanceof Error) { + const messageKey = SAFE_LOCAL_AUDIO_MESSAGE_KEYS.get(error.message); + if (messageKey) { + return analysisMessage(messageKey); + } } return analysisMessage("unsupportedLocalAudio"); } @@ -161,7 +162,7 @@ async function browserFallback(command: string, args?: Record): state: "failed", error: { code: "not_found", - message: analysisMessage("analysisStateFailed") + message: analysisMessage("analysisJobNotFound") } }); } From a37583aa7cf0507de6f90357fad49abc4599f32d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 23:20:17 -0700 Subject: [PATCH 19/31] test(app): isolate localized source error from pdfjs --- .../src/App.localized-source-error.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.localized-source-error.test.tsx b/apps/desktop/src/App.localized-source-error.test.tsx index 1531ec9b4..949d35ce8 100644 --- a/apps/desktop/src/App.localized-source-error.test.tsx +++ b/apps/desktop/src/App.localized-source-error.test.tsx @@ -1,7 +1,19 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; +// App mounts the Score surface, whose pdf.js bridge depends on browser canvas +// globals such as DOMMatrix that jsdom does not provide. This regression only +// exercises source-selection localization, so isolate that unrelated boundary +// exactly as the canonical App suite does. +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + const originalLanguage = navigator.language; const originalInternals = window.__TAURI_INTERNALS__; const originalInvoke = window.__TAURI_INVOKE__; From 6f144f61d87ec7917ce57555daf659cbe044ec13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:39:08 -0700 Subject: [PATCH 20/31] test(score): keep storage localization mock complete --- apps/desktop/src/features/score/ScoreView.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index 2b4595cae..a3bb93c0b 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -33,6 +33,8 @@ vi.mock("../../i18n", () => ({ scoreAttachFailed: "Could not attach the score PDF.", scoreReadFailed: "Could not open the score PDF.", scoreRemoveFailed: "Could not remove the score PDF.", + scoreDesktopOnly: "Score PDFs are only available in the desktop app.", + scoreInvalidResponse: "The score could not be prepared. Try adding it again.", scoreRequiresProject: "Scores attach to the active analysis project." })[key] ?? key, detectPreferredLocale: () => "en" From dba7f6bfc553a205ed32904357389e31716da33d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:44:33 -0700 Subject: [PATCH 21/31] test(app): align localized failure contracts --- apps/desktop/src/App.test.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 0c77eef94..a229f6418 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -453,7 +453,7 @@ describe("App", () => { expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); }); - it("preserves safe file-read failure copy from the intake bridge", async () => { + it("localizes safe file-read failure copy from the intake bridge", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Could not read the selected audio file.")); render(); @@ -461,8 +461,9 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); await waitFor(() => { - expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy(); + expect(screen.getByText(/selected audio file could not be read.*choose the file again/i)).toBeTruthy(); }); + expect(screen.queryByText(/^Could not read the selected audio file\.$/i)).toBeNull(); expect(screen.queryByText(/analysis failed during execution/i)).toBeNull(); }); @@ -1150,7 +1151,7 @@ describe("App", () => { expect(input).not.toHaveAttribute("aria-describedby"); }); - it("handles YouTube import failure with a message", async () => { + it("redacts bridge detail when YouTube import fails after URL admission", async () => { tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); render(); @@ -1163,14 +1164,15 @@ describe("App", () => { await waitFor(() => { const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); + expect(alert).toHaveTextContent(/Failed to import YouTube URL/i); + expect(alert).not.toHaveTextContent(/This video is age restricted/i); expect(alert).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", alert.id); }); }); - it("handles generic exception during YouTube import", async () => { + it("redacts generic bridge exceptions during YouTube import", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Network Error")); render(); @@ -1182,7 +1184,9 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Network Error/i)).toBeTruthy(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(/Failed to import YouTube URL/i); + expect(alert).not.toHaveTextContent(/Network Error/i); }); }); From 6934dfaef6bd1cdf7260d5d584d8afd71158b784 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:45:05 -0700 Subject: [PATCH 22/31] test(analysis): reproduce admitted YouTube failure guidance --- .../analysis.youtube-import-guidance.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/desktop/src/lib/analysis.youtube-import-guidance.test.ts diff --git a/apps/desktop/src/lib/analysis.youtube-import-guidance.test.ts b/apps/desktop/src/lib/analysis.youtube-import-guidance.test.ts new file mode 100644 index 000000000..c537d4226 --- /dev/null +++ b/apps/desktop/src/lib/analysis.youtube-import-guidance.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { importYoutubeUrl } from "./analysis"; + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("YouTube import failure guidance", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("gives an admitted English YouTube link a connection-or-availability next action", async () => { + setNavigatorLanguage("en-US"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("provider detail must stay private"); + }; + + const result = await importYoutubeUrl("https://youtube.com/watch?v=abc123DEF45"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again." + ); + expect(result.error.message).not.toContain("standard YouTube video link"); + expect(result.error.message).not.toContain("provider detail must stay private"); + } + }); + + it("gives an admitted Korean YouTube link the same actionable semantics", async () => { + setNavigatorLanguage("ko-KR"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = async () => { + throw new Error("provider detail must stay private"); + }; + + const result = await importYoutubeUrl("https://youtu.be/abc123DEF45"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "유튜브 URL 가져오기에 실패했습니다. 네트워크 연결과 영상 이용 가능 여부를 확인한 뒤 다시 시도해 주세요." + ); + expect(result.error.message).not.toContain("표준 유튜브 영상"); + expect(result.error.message).not.toContain("provider detail must stay private"); + } + }); +}); From 8172227c83c53fd805a9ce287870031df8c7c366 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:46:05 -0700 Subject: [PATCH 23/31] fix(copy): guide admitted YouTube import failures --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 71e124824..d2128cf45 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -103,7 +103,7 @@ "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", - "youtubeImportFailed": "Failed to import YouTube URL. Check that it is a standard YouTube video link, then try again.", + "youtubeImportFailed": "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again.", "youtubeLinkGuidance": "Use a standard YouTube video link (youtube.com/watch or youtu.be).", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", From 65d4e6b416edb3a81e8d9d23995e4f70081ed907 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 00:47:31 -0700 Subject: [PATCH 24/31] fix(copy): localize admitted YouTube failure action --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index f26225115..fb4e33726 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -103,7 +103,7 @@ "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", - "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다. 표준 유튜브 영상 주소인지 확인한 뒤 다시 시도해 주세요.", + "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다. 네트워크 연결과 영상 이용 가능 여부를 확인한 뒤 다시 시도해 주세요.", "youtubeLinkGuidance": "표준 유튜브 영상 링크(youtube.com/watch 또는 youtu.be)를 사용해 주세요.", "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", From 7cd1210f7e8d8e335051c73cf25d029bab93aef1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:09:55 -0700 Subject: [PATCH 25/31] test(ux): reproduce pre-network YouTube guidance mismatch --- .../src/App.youtube-url-guidance.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/App.youtube-url-guidance.test.tsx diff --git a/apps/desktop/src/App.youtube-url-guidance.test.tsx b/apps/desktop/src/App.youtube-url-guidance.test.tsx new file mode 100644 index 000000000..603cd0755 --- /dev/null +++ b/apps/desktop/src/App.youtube-url-guidance.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +// App mounts the Score surface, whose pdf.js bridge depends on browser canvas +// globals such as DOMMatrix that jsdom does not provide. This regression only +// exercises pre-network YouTube URL admission guidance. +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +const originalLanguage = navigator.language; +const originalInternals = window.__TAURI_INTERNALS__; +const originalInvoke = window.__TAURI_INVOKE__; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("App YouTube URL admission guidance", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + window.__TAURI_INTERNALS__ = originalInternals; + window.__TAURI_INVOKE__ = originalInvoke; + }); + + it("uses format guidance for a URL rejected before any import attempt", async () => { + setNavigatorLanguage("en-US"); + window.__TAURI_INTERNALS__ = undefined; + window.__TAURI_INVOKE__ = undefined; + + render(); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Use a standard YouTube video link (youtube.com/watch or youtu.be)."); + expect(alert).not.toHaveTextContent(/check your connection/i); + }); +}); From 5ea3a4c98c3c0d67ec5d274af21fe982c5844b08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:13:23 -0700 Subject: [PATCH 26/31] fix(ux): use format guidance before YouTube network admission --- apps/desktop/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index e30d8a5eb..4f504cb15 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -440,13 +440,13 @@ export function App() { setSelectionErrorSource(null); const normalizedUrl = youtubeUrl.trim(); if (!normalizedUrl) { - setSelectionError(t("youtubeImportFailed")); + setSelectionError(t("youtubeLinkGuidance")); setSelectionErrorSource("youtube"); return; } if (!isSupportedYoutubeUrl(normalizedUrl)) { - setSelectionError(t("youtubeImportFailed")); + setSelectionError(t("youtubeLinkGuidance")); setSelectionErrorSource("youtube"); return; } From a07d0d71d42743835bcc7c596003243e7fd7b9a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:15:03 -0700 Subject: [PATCH 27/31] fix(copy): keep format rejection compatible with URL guidance tests --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d2128cf45..ee9fe2b47 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -104,7 +104,7 @@ "importYoutube": "Import YouTube", "importingYoutube": "Importing...", "youtubeImportFailed": "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again.", - "youtubeLinkGuidance": "Use a standard YouTube video link (youtube.com/watch or youtu.be).", + "youtubeLinkGuidance": "Failed to import YouTube URL. Use a standard YouTube video link (youtube.com/watch or youtu.be).", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", From 042caed0c0af4ada9207c928be2a51b94416ebbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:02:38 -0700 Subject: [PATCH 28/31] test(copy): keep invalid YouTube guidance format-specific --- .../src/lib/analysis.localized-errors.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/analysis.localized-errors.test.ts b/apps/desktop/src/lib/analysis.localized-errors.test.ts index cf02ae7d6..e39d72b95 100644 --- a/apps/desktop/src/lib/analysis.localized-errors.test.ts +++ b/apps/desktop/src/lib/analysis.localized-errors.test.ts @@ -36,6 +36,18 @@ describe("analysis buyer-visible fallback localization", () => { } }); + it("describes an invalid English YouTube URL as a format problem before import", async () => { + setNavigatorLanguage("en-US"); + const result = await importYoutubeUrl("https://example.com/not-youtube"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + "Use a standard YouTube video link (youtube.com/watch or youtu.be)." + ); + } + }); + it.each([ ["Could not read the selected audio file.", "선택한 오디오 파일을 읽을 수 없습니다. 파일을 다시 선택해 주세요."], ["Could not prepare the local project workspace.", "프로젝트 작업 공간을 준비할 수 없습니다. 저장 위치를 확인한 뒤 다시 시도해 주세요."], @@ -97,4 +109,4 @@ describe("analysis buyer-visible fallback localization", () => { "프로젝트는 BandScope 데스크톱 앱에서 열어 주세요." ); }); -}); +}); \ No newline at end of file From 99125400725cb8c5f48e908f8d3b7ba3b9edce58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:03:15 -0700 Subject: [PATCH 29/31] fix(copy): keep invalid YouTube guidance format-specific --- apps/desktop/src/locales/en/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index ee9fe2b47..7f7d69f42 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -104,7 +104,7 @@ "importYoutube": "Import YouTube", "importingYoutube": "Importing...", "youtubeImportFailed": "Failed to import YouTube URL. Check your connection and make sure the video is available, then try again.", - "youtubeLinkGuidance": "Failed to import YouTube URL. Use a standard YouTube video link (youtube.com/watch or youtu.be).", + "youtubeLinkGuidance": "Use a standard YouTube video link (youtube.com/watch or youtu.be).", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", @@ -188,4 +188,4 @@ "confidenceShortLow": "Low", "confidenceShortMedium": "Medium", "confidenceShortHigh": "High" -} +} \ No newline at end of file From c1a2488d2b90b03fb5cb7df3402b36858415b42f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:07:00 -0700 Subject: [PATCH 30/31] test(copy): label pending transcription for selected role --- .../src/features/workspace/Workspace.test.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 6c5c7f5c6..dfd6a4cd7 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -101,6 +101,26 @@ describe("Workspace", () => { expect(transcribeButton.title).toBe("Show this part's bass notes on the groove map."); }); + it("labels unavailable transcription for the selected non-bass role instead of bass", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal" + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const pendingButton = screen.getByRole("button", { name: "Lead Vocal · Coming soon" }); + expect(pendingButton.textContent).toBe("Lead Vocal · Coming soon"); + expect(pendingButton.textContent).not.toContain("Map bass notes"); + expect(pendingButton.getAttribute("title")).toBe( + "Lead Vocal part is coming soon — bass is ready first." + ); + }); + it("renders bass transcription in the dark rehearsal cockpit system", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { @@ -270,4 +290,4 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); -}); +}); \ No newline at end of file From ad82ef90582aa4793df57da5eaf3f38e2896d029 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:08:57 -0700 Subject: [PATCH 31/31] fix(copy): name pending transcription for selected role --- apps/desktop/src/features/workspace/Workspace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index f08347c82..92dad17f4 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -402,7 +402,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp variant="outline" className="min-h-11 cursor-not-allowed border-white/10 bg-white/5 font-semibold text-slate-500 opacity-70" > - {t("transcribeBassLabel")} + {`${activeRoleDetails?.name ?? activeRole} · ${t("comingSoon")}`} )}