diff --git a/CHANGELOG.md b/CHANGELOG.md
index eea696893..c3f3070e5 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
+
+- Localize Workspace export, stem-player, loop, solo, mute, and transcription controls in English and Korean, including single-pass literal placeholder interpolation that never reinterprets placeholder-looking replacement text and an accessible localized role fallback.
+
## [0.1.3] - 2026-04-29
### Fixed
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index a3da5ffe6..3cbdbca31 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -86,6 +86,7 @@ describe("Workspace", () => {
});
it("enables bass transcription from selected role metadata rather than role id text", () => {
+ setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
@@ -96,11 +97,30 @@ 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: /Transcribe Bass/ }) as HTMLButtonElement;
expect(transcribeButton.disabled).toBe(false);
expect(transcribeButton.title).toBe("Transcribe part");
});
+ it("names unavailable transcription for the selected non-bass role", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ id: "guitar-role",
+ name: "Guitar"
+ };
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Guitar" }));
+
+ const label = "Guitar transcription is coming soon. Bass is ready first.";
+ const transcribeButton = screen.getByRole("button", { name: label });
+ expect(transcribeButton).toHaveTextContent("Transcribe part");
+ expect(transcribeButton).toHaveAttribute("aria-disabled", "true");
+ expect(transcribeButton).toHaveAttribute("title", label);
+ });
+
it("renders bass transcription in the dark rehearsal cockpit system", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
@@ -269,5 +289,8 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "큐 시트 내보내기 (CSV)" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "차트 요약 내보내기 (JSON)" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "핸드오프 데이터 내보내기 (JSON)" })).toBeTruthy();
});
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 71546b524..dda6628a4 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -150,6 +150,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
+ const transcriptionUnavailableLabel = t("transcriptionComingSoon", {
+ roleName: activeRoleDetails?.name ?? t("thisRole")
+ });
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -263,7 +266,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
className="min-h-10 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 shadow-[0_10px_30px_rgba(34,211,238,0.16)] hover:bg-cyan-300/20 hover:text-white"
>
- Export Cue Sheet (CSV)
+ {t("exportCueSheetCsv")}
@@ -348,61 +351,62 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{activeRole && (
-
Stem Player
+
{t("stemPlayerLabel")}
{activeRoleDetails?.name ?? activeRole}
{canTranscribeBass ? (
) : (
)}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..03458e299 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -76,3 +76,33 @@ describe("i18n", () => {
});
});
});
+
+describe("translator placeholder interpolation", () => {
+ it("does not recursively interpolate placeholder text inside replacement values", () => {
+ const t = createTranslator("en");
+
+ expect(
+ t("transcriptionComingSoon", { roleName: "Bass {roleName}" })
+ ).toBe("Bass {roleName} transcription is coming soon. Bass is ready first.");
+ });
+
+ it("does not interpolate a later placeholder inside an earlier replacement value", () => {
+ const t = createTranslator("en");
+
+ expect(
+ t("chordEditAriaLabel", {
+ roleName: "{sectionLabel}",
+ sectionLabel: "Bridge",
+ chord: "Cmaj7"
+ })
+ ).toBe("Edit chord for {sectionLabel} in Bridge, current Cmaj7");
+ });
+
+ it("preserves replacement characters literally", () => {
+ const t = createTranslator("en");
+
+ expect(t("transcriptionComingSoon", { roleName: "$& [lead].*" })).toBe(
+ "$& [lead].* transcription is coming soon. Bass is ready first."
+ );
+ });
+});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..0405ddfe8 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -11,10 +11,21 @@ const dictionaries = {
ko: koCommon
} as const;
-/** Documented. */
+/** Create a locale-bound translator with single-pass literal placeholder interpolation. */
export function createTranslator(locale: Locale = "en") {
- return function t(key: TranslationKey): string {
- return dictionaries[locale][key] ?? dictionaries.en[key];
+ return function translate(
+ key: TranslationKey,
+ variables?: Readonly
>
+ ): string {
+ let text = dictionaries[locale][key] ?? dictionaries.en[key];
+ if (variables) {
+ text = text.replace(/\{([^{}]+)\}/g, (placeholder, variableName: string) =>
+ Object.prototype.hasOwnProperty.call(variables, variableName)
+ ? variables[variableName]
+ : placeholder
+ );
+ }
+ return text;
};
}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 39f716d50..8ac451add 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -148,5 +148,19 @@
"practiceProgressRegionLabel": "Practice Progress",
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
- "increasePracticeProgressLabel": "Increase progress"
+ "increasePracticeProgressLabel": "Increase progress",
+ "exportCueSheetCsv": "Export Cue Sheet (CSV)",
+ "exportChartJson": "Export Chart (JSON)",
+ "exportHandoffJson": "Export Handoff (JSON)",
+ "stemPlayerLabel": "Stem Player",
+ "playStem": "Play stem",
+ "playStemSoon": "Stem playback is coming soon.",
+ "loopSection": "Loop section",
+ "loopSectionSoon": "Section looping is coming soon.",
+ "soloMuteOthers": "Solo / mute others",
+ "soloMuteOthersSoon": "Solo and mute controls are coming soon.",
+ "transcribeBass": "Transcribe Bass",
+ "transcribePart": "Transcribe part",
+ "transcriptionComingSoon": "{roleName} transcription is coming soon. Bass is ready first.",
+ "thisRole": "This role"
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 371884abb..10d391295 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -148,5 +148,19 @@
"practiceProgressRegionLabel": "연습 진척도",
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
- "increasePracticeProgressLabel": "진척도 증가"
+ "increasePracticeProgressLabel": "진척도 증가",
+ "exportCueSheetCsv": "큐 시트 내보내기 (CSV)",
+ "exportChartJson": "차트 요약 내보내기 (JSON)",
+ "exportHandoffJson": "핸드오프 데이터 내보내기 (JSON)",
+ "stemPlayerLabel": "스템 플레이어",
+ "playStem": "스템 재생",
+ "playStemSoon": "스템 재생은 곧 제공됩니다",
+ "loopSection": "구간 반복",
+ "loopSectionSoon": "구간 반복은 곧 제공됩니다",
+ "soloMuteOthers": "솔로 / 나머지 음소거",
+ "soloMuteOthersSoon": "솔로 / 나머지 음소거는 곧 제공됩니다",
+ "transcribeBass": "베이스 채보",
+ "transcribePart": "파트 채보",
+ "transcriptionComingSoon": "{roleName} 채보는 곧 제공됩니다. 베이스가 먼저 준비되었습니다.",
+ "thisRole": "이 역할"
}