From 87441e423e4e3cdd96df5bfa3b44c463092d668a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:21:57 +0000 Subject: [PATCH 01/12] feat(workspace): name tonight's audio next action from Settings Settings no longer says coming soon. It lists admitted local audio formats and names the next rehearsal action: choose a supported file, or open tonight's map once a song is ready. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + apps/desktop/src/App.test.tsx | 54 ++++++++++++- apps/desktop/src/App.tsx | 50 +++++++++--- .../settings/RehearsalSettings.test.tsx | 60 ++++++++++++++ .../features/settings/RehearsalSettings.tsx | 78 +++++++++++++++++++ apps/desktop/src/features/settings/index.tsx | 52 +------------ .../settings/rehearsalSettings.test.ts | 49 ++++++++++++ .../features/settings/rehearsalSettings.ts | 31 ++++++++ apps/desktop/src/locales/en/common.json | 10 +++ apps/desktop/src/locales/ko/common.json | 10 +++ apps/desktop/vite.config.ts | 4 +- docs/design-system/component-contract.md | 2 +- 15 files changed, 340 insertions(+), 65 deletions(-) create mode 100644 apps/desktop/src/features/settings/RehearsalSettings.test.tsx create mode 100644 apps/desktop/src/features/settings/RehearsalSettings.tsx create mode 100644 apps/desktop/src/features/settings/rehearsalSettings.test.ts create mode 100644 apps/desktop/src/features/settings/rehearsalSettings.ts diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..381f29524 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Settings must name which audio this device can open and the next rehearsal action. It must not say coming soon. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..e876ceae0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,6 +68,7 @@ Last updated: 2026-03-11 - BandScope is not only a shell around chord labels, stems, and ranges. - The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority. - These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer. +- The desktop Settings control lists admitted local audio formats and names the next action: choose a supported file, or open tonight's rehearsal map once a song is ready. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..6af250e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Settings names which audio this device can open and the next rehearsal action instead of saying coming soon. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..548586cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +Settings must name admitted local audio formats and the next rehearsal action. Help and Stem Lab stay out of this slice. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..2d427224b 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -217,7 +217,7 @@ describe("App", () => { expect(screen.getByRole("button", { name: /^Workspace$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); - expect(fireEvent.click(screen.getByRole("button", { name: /settings coming soon/i }))).toBe(false); + expect(screen.getByRole("button", { name: /See which audio this device can open/i })).not.toHaveAttribute("aria-disabled"); expect(fireEvent.click(screen.getByRole("button", { name: /help coming soon/i }))).toBe(false); const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i }); const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" }); @@ -1553,16 +1553,62 @@ describe("App", () => { }); - it("renders Settings and Help as focusable aria-disabled controls", () => { + it("keeps Help coming soon while Settings names the next audio action", () => { render(); - const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); + const settingsButton = screen.getByRole("button", { name: "See which audio this device can open" }); const helpButton = screen.getByRole("button", { name: "Help coming soon" }); - expect(settingsButton).toHaveAttribute("aria-disabled", "true"); + expect(settingsButton).not.toHaveAttribute("aria-disabled"); expect(settingsButton).not.toHaveAttribute("disabled"); expect(helpButton).toHaveAttribute("aria-disabled", "true"); expect(helpButton).not.toHaveAttribute("disabled"); }); + it("opens Settings to name admitted formats and start the local-audio picker", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + + fireEvent.click(screen.getByRole("button", { name: "See which audio this device can open" })); + expect(screen.getByRole("button", { name: "See which audio this device can open" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByText("Tonight's audio")).toBeTruthy(); + expect(screen.getByText(".wav")).toBeTruthy(); + expect(screen.getByText(".m4a")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Choose a supported file/i })); + await waitFor(() => { + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + }); + expect(screen.getByRole("heading", { name: /Workspace Home/i })).toBeTruthy(); + expect(screen.queryByText("Tonight's audio")).toBeNull(); + }); + + it("returns from Settings to tonight's rehearsal map after a song is ready", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: "See which audio this device can open" })); + expect(screen.getByText(/Tonight's map is ready/i)).toBeTruthy(); + expect(screen.queryByText(/Song Timeline/i)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /Open tonight's rehearsal map/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + expect(document.getElementById("main-content")).toHaveFocus(); + expect(scrollIntoView).toHaveBeenCalled(); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("keeps the Score view disabled until a song is loaded", () => { render(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..d15e94129 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -45,6 +45,7 @@ import { } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; +import { RehearsalSettings } from "./features/settings/RehearsalSettings"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; @@ -54,11 +55,12 @@ import { Toaster } from "@/components/ui/sonner"; const ANALYSIS_POLL_INTERVAL_MS = 250; const MAX_ERROR_DETAIL_LENGTH = 220; +const SOURCE_CONTROLS_FOCUS_ID = "source-controls-choose-audio"; const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:Users|home|var|tmp|private|Volumes)\/[^\s"'<>]+)/g; const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; -type RehearsalView = "workspace" | "score"; +type RehearsalView = "workspace" | "score" | "settings"; const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, @@ -517,7 +519,8 @@ export function App() { return ; }; - const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const currentView: RehearsalView = + activeView === "settings" ? "settings" : jobResult && activeView === "score" ? "score" : "workspace"; /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { @@ -535,6 +538,24 @@ export function App() { setActiveView(view); }; + /** Leave Settings and start the local-audio picker. */ + const handleSettingsChooseAudio = () => { + setActiveView("workspace"); + void handleChooseLocalAudio(); + }; + + /** Leave Settings and show tonight's rehearsal map. */ + const handleSettingsOpenMap = () => { + setActiveView("workspace"); + window.setTimeout(() => { + const main = document.getElementById("main-content"); + if (main instanceof HTMLElement) { + main.scrollIntoView?.({ block: "start" }); + main.focus(); + } + }, 0); + }; + return (
@@ -613,11 +634,15 @@ export function App() {
@@ -635,7 +660,7 @@ export function App() {
-
+
From e7d48bffc951874d2084e79197e901199b8f4137 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 14:22:49 +0900 Subject: [PATCH 08/12] fix(settings): guard picker during active work --- apps/desktop/src/App.test.tsx | 54 +++++++++++++++++++ apps/desktop/src/App.tsx | 4 ++ .../settings/RehearsalSettings.test.tsx | 12 +++-- .../features/settings/RehearsalSettings.tsx | 6 ++- apps/desktop/vite.config.ts | 2 +- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index b5dc8c853..7478dd913 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1588,6 +1588,60 @@ describe("App", () => { expect(screen.queryByText("Tonight's audio")).toBeNull(); }); + it.each(["queued", "running"] as const)("keeps the Settings chooser disabled while analysis is %s", async (state) => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ state })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) }; + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + fireEvent.click(settingsChooseButton); + + expect(screen.getByTitle("late-night-set.wav")).toBeTruthy(); + expect(screen.queryByText("replacement.wav")).toBeNull(); + }); + + it("keeps the Settings chooser disabled while analysis startup is pending", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockImplementationOnce(() => new Promise(() => undefined)); + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) }; + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + fireEvent.click(settingsChooseButton); + + expect(screen.getByTitle("late-night-set.wav")).toBeTruthy(); + expect(screen.queryByText("replacement.wav")).toBeNull(); + }); + + it("keeps the Settings chooser disabled while YouTube import is pending", async () => { + tauriInvoke.mockImplementation(() => new Promise(() => undefined)); + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + fireEvent.change(screen.getByPlaceholderText(/YouTube URL/i), { + target: { value: "https://youtube.com/watch?v=abc123DEF45" } + }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + }); + it("returns from Settings to tonight's rehearsal map after a song is ready", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 1e8f755fe..0611c9d41 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -540,6 +540,9 @@ export function App() { /** Leave Settings and start the local-audio picker. */ const handleSettingsChooseAudio = () => { + if (analysisInFlight || isStarting || isImporting) { + return; + } setActiveView("workspace"); void handleChooseLocalAudio(); }; @@ -885,6 +888,7 @@ export function App() {
{currentView === "settings" ? ( { const onChooseAudio = vi.fn(); const onOpenMap = vi.fn(); setNavigatorLanguage("en-US"); - render(); + render(); expect(screen.getByText("Tonight's audio")).toBeTruthy(); expect(screen.getByLabelText("Audio this device can open")).toBeTruthy(); @@ -39,7 +39,7 @@ describe("RehearsalSettings", () => { const onChooseAudio = vi.fn(); const onOpenMap = vi.fn(); setNavigatorLanguage("en-US"); - render(); + render(); expect(screen.getByText(/Tonight's map is ready/i)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /Open tonight's rehearsal map/i })); @@ -50,11 +50,17 @@ describe("RehearsalSettings", () => { it("keeps Korean copy on the same next actions", () => { const onChooseAudio = vi.fn(); setNavigatorLanguage("ko-KR"); - render( undefined} />); + render( undefined} />); expect(screen.getByText("오늘 쓸 오디오")).toBeTruthy(); expect(screen.getByText("이 기기에서 열 수 있는 오디오")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /지원되는 파일을 고르세요/i })); expect(onChooseAudio).toHaveBeenCalledTimes(1); }); + + it("disables the choose-file action while the app is busy", () => { + render( undefined} onOpenMap={() => undefined} />); + + expect(screen.getByRole("button", { name: /Choose a supported file/i })).toBeDisabled(); + }); }); diff --git a/apps/desktop/src/features/settings/RehearsalSettings.tsx b/apps/desktop/src/features/settings/RehearsalSettings.tsx index 182b8b371..8b0f9454e 100644 --- a/apps/desktop/src/features/settings/RehearsalSettings.tsx +++ b/apps/desktop/src/features/settings/RehearsalSettings.tsx @@ -8,13 +8,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { admittedAudioFormats, settingsNextAction } from "./settingsModel"; interface RehearsalSettingsProps { + disabled: boolean; songReady: boolean; onChooseAudio: () => void; onOpenMap: () => void; } /** Settings names admitted local audio and the next rehearsal action. */ -export function RehearsalSettings({ songReady, onChooseAudio, onOpenMap }: RehearsalSettingsProps) { +export function RehearsalSettings({ disabled, songReady, onChooseAudio, onOpenMap }: RehearsalSettingsProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); const formats = admittedAudioFormats(SUPPORTED_AUDIO_FORMATS); const action = settingsNextAction(songReady); @@ -55,6 +56,7 @@ export function RehearsalSettings({ songReady, onChooseAudio, onOpenMap }: Rehea

); })} -
@@ -706,6 +691,21 @@ export function App() { ); })} +
From 828004be2e98c6bd370c12edde9091c8ff01f2d2 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 19:10:34 +0900 Subject: [PATCH 11/12] fix(settings): keep desktop and compact navigation reachable --- apps/desktop/src/App.test.tsx | 9 +++++++-- apps/desktop/src/App.tsx | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 1137fd419..3084ee343 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -218,7 +218,7 @@ describe("App", () => { expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i }); - expect(settingsButtons).toHaveLength(2); + expect(settingsButtons).toHaveLength(3); for (const settingsButton of settingsButtons) { expect(settingsButton).not.toHaveAttribute("aria-disabled"); } @@ -1575,11 +1575,16 @@ describe("App", () => { render(); const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i }); const helpButton = screen.getByRole("button", { name: "Help coming soon" }); - expect(settingsButtons).toHaveLength(2); + expect(settingsButtons).toHaveLength(3); for (const settingsButton of settingsButtons) { expect(settingsButton).not.toHaveAttribute("aria-disabled"); expect(settingsButton).not.toHaveAttribute("disabled"); } + fireEvent.click(screen.getByRole("button", { name: "See which audio this device can open" })); + expect(screen.getByRole("button", { name: "See which audio this device can open" })).toHaveAttribute( + "aria-current", + "page" + ); expect(helpButton).toHaveAttribute("aria-disabled", "true"); expect(helpButton).not.toHaveAttribute("disabled"); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0ebb29c97..239f5cbfb 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -610,6 +610,21 @@ export function App() { ); })} +
From 974ce500cf7f5f2a11180528760b1f43532069d4 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 19:16:58 +0900 Subject: [PATCH 12/12] fix(settings): correct navigation accessibility labels --- apps/desktop/src/App.test.tsx | 4 ++-- apps/desktop/src/App.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3084ee343..fe41e97ca 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1580,8 +1580,8 @@ describe("App", () => { expect(settingsButton).not.toHaveAttribute("aria-disabled"); expect(settingsButton).not.toHaveAttribute("disabled"); } - fireEvent.click(screen.getByRole("button", { name: "See which audio this device can open" })); - expect(screen.getByRole("button", { name: "See which audio this device can open" })).toHaveAttribute( + fireEvent.click(screen.getByRole("button", { name: /See which audio this device can open.*compact/i })); + expect(screen.getByRole("button", { name: /See which audio this device can open.*compact/i })).toHaveAttribute( "aria-current", "page" ); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 239f5cbfb..aeedc6990 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -613,7 +613,7 @@ export function App() {