diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..8958380c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,7 @@
## Project overview
- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
+- Rehearsal help must name the next action: choose a local song, start analysis, wait, retry after failure, or open tonight's map. Stem playback and a licensed demo remain later work.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..690dfba62 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-03-11
+Last updated: 2026-08-21
## Brand source
@@ -86,6 +86,7 @@ Last updated: 2026-03-11
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
+ - a rehearsal-help surface that names the next local-first action (choose a song, start analysis, wait, retry, or open the map) without claiming stem playback or a licensed demo
## Confidence, edits, and provenance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..ce5ea3e51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Rehearsal help now names the next action: choose a local song, start analysis, wait while analysis runs, retry after a failure, or open tonight's map.
- 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..7e4e7b06f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into
Three layers, decoupled through shared contracts:
-- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
+- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings, help). The help control names tonight's next local-first action instead of a coming-soon label. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx
new file mode 100644
index 000000000..693130a70
--- /dev/null
+++ b/apps/desktop/src/App.rehearsalHelpFailure.integration.test.tsx
@@ -0,0 +1,335 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { App } from "./App";
+
+const analysisMocks = vi.hoisted(() => ({
+ getAnalysisJobStatus: vi.fn(),
+ importYoutubeUrl: vi.fn(),
+ isSupportedYoutubeUrl: vi.fn(),
+ loadProject: vi.fn(),
+ saveProject: vi.fn(),
+ selectLocalAudioSource: vi.fn(),
+ startAnalysisJob: vi.fn(),
+ subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined),
+}));
+
+vi.mock("./lib/analysis", () => ({
+ createDefaultAnalysisRequest: () => ({
+ sourceKind: "demo",
+ sourceLabel: "Late Night Set",
+ roleFocus: ["bass-guitar", "keys-right", "lead-vocal"],
+ }),
+ getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus,
+ importYoutubeUrl: analysisMocks.importYoutubeUrl,
+ isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl,
+ loadProject: analysisMocks.loadProject,
+ MAX_YOUTUBE_URL_LENGTH: 2048,
+ saveProject: analysisMocks.saveProject,
+ selectLocalAudioSource: analysisMocks.selectLocalAudioSource,
+ startAnalysisJob: analysisMocks.startAnalysisJob,
+ subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates,
+}));
+
+vi.mock("./features/score/ScoreView", () => ({
+ ScoreView: () =>
Score view
,
+}));
+
+function bootstrap(projectId: string, fileName: string) {
+ return {
+ projectId,
+ sourceMode: "reference",
+ projectRoot: `/tmp/bandscope/projects/${projectId}`,
+ cacheRoot: `/tmp/bandscope/cache/${projectId}`,
+ tempRoot: `/tmp/bandscope/temp/${projectId}`,
+ source: {
+ sourcePath: `/Users/test/Music/${fileName}`,
+ fileName,
+ extension: "wav",
+ fileSizeBytes: 1024000,
+ },
+ };
+}
+
+/**
+ * Security Notes:
+ * - Local paths in this suite are synthetic test fixtures and are never rendered in customer-facing copy.
+ * - The suite mocks the existing picker and analysis boundaries; it adds no network access or IPC permission.
+ */
+describe("App rehearsal-help failure recovery", () => {
+ beforeEach(() => {
+ for (const mock of Object.values(analysisMocks)) {
+ mock.mockReset();
+ }
+ analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false);
+ analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined);
+ });
+
+ it("gives compact rehearsal help a distinct accessible name", () => {
+ render( );
+
+ const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i });
+ const compactHelp = within(compactNav).getByRole("button", {
+ name: /^open rehearsal help compact view$/i,
+ });
+
+ expect(screen.getByRole("button", { name: /^open rehearsal help$/i })).toBeTruthy();
+ fireEvent.click(compactHelp);
+ expect(screen.getByTestId("rehearsal-help-dialog")).toBeTruthy();
+ });
+
+ it("does not describe a project-load error as an analysis failure", async () => {
+ analysisMocks.loadProject.mockRejectedValueOnce(new Error("Broken project fixture"));
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /open project/i }));
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /choose a local song first/i,
+ );
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).not.toMatch(
+ /analysis did not finish/i,
+ );
+ });
+
+ it("advances from retry to start analysis after a different local song is selected", async () => {
+ analysisMocks.selectLocalAudioSource
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "failed-song.wav") })
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") });
+ analysisMocks.startAnalysisJob.mockResolvedValueOnce({
+ jobId: "job-help-failed",
+ state: "failed",
+ requestedAt: "2026-08-21T05:00:00.000Z",
+ updatedAt: "2026-08-21T05:00:01.000Z",
+ error: {
+ code: "engine_unavailable",
+ message: "Analysis engine is unavailable.",
+ },
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/failed-song\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toMatch(/analysis engine is unavailable/i);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ let helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /choose another local song and try again/i,
+ );
+ fireEvent.click(within(helpDialog).getByRole("button", { name: /choose another song/i }));
+
+ await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ helpDialog = screen.getByTestId("rehearsal-help-dialog");
+
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /start analysis to get tonight's first cues/i,
+ );
+ expect(within(helpDialog).queryByRole("button", { name: /choose another song/i })).toBeNull();
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("forgets the previous analyzed song when a different local source is selected", async () => {
+ analysisMocks.selectLocalAudioSource
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-a", "analyzed-song.wav") })
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-b", "fresh-song.wav") });
+ analysisMocks.startAnalysisJob.mockResolvedValueOnce({
+ jobId: "job-help-succeeded",
+ state: "succeeded",
+ requestedAt: "2026-08-21T05:10:00.000Z",
+ updatedAt: "2026-08-21T05:10:01.000Z",
+ progressLabel: "Analysis ready",
+ progressStage: "ready",
+ progressPercent: 100,
+ cacheStatus: "disabled",
+ result: createDemoRehearsalSong(),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/analyzed-song\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /start analysis to get tonight's first cues/i,
+ );
+ expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull();
+ });
+
+ it("clears a stale save failure when a different local source is selected", async () => {
+ analysisMocks.selectLocalAudioSource
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-a", "save-failed-song.wav") })
+ .mockResolvedValueOnce({ ok: true, bootstrap: bootstrap("project-save-b", "fresh-song.wav") });
+ analysisMocks.startAnalysisJob.mockResolvedValueOnce({
+ jobId: "job-help-save-failure",
+ state: "succeeded",
+ requestedAt: "2026-08-22T06:00:00.000Z",
+ updatedAt: "2026-08-22T06:00:01.000Z",
+ progressLabel: "Analysis ready",
+ progressStage: "ready",
+ progressPercent: 100,
+ cacheStatus: "disabled",
+ result: createDemoRehearsalSong(),
+ });
+ analysisMocks.saveProject.mockRejectedValueOnce(new Error("Disk unavailable"));
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/save-failed-song\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /save project/i }));
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/disk unavailable/i));
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/fresh-song\.wav/i)).toBeTruthy());
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("forgets the previous analyzed song when a YouTube source is imported", async () => {
+ analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true);
+ analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
+ ok: true,
+ bootstrap: bootstrap("project-local", "analyzed-song.wav"),
+ });
+ analysisMocks.startAnalysisJob.mockResolvedValueOnce({
+ jobId: "job-help-youtube-reset",
+ state: "succeeded",
+ requestedAt: "2026-08-21T05:15:00.000Z",
+ updatedAt: "2026-08-21T05:15:01.000Z",
+ progressLabel: "Analysis ready",
+ progressStage: "ready",
+ progressPercent: 100,
+ cacheStatus: "disabled",
+ result: createDemoRehearsalSong(),
+ });
+ analysisMocks.importYoutubeUrl.mockResolvedValueOnce({
+ ok: true,
+ bootstrap: bootstrap("project-youtube", "imported-song.m4a"),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/analyzed-song\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
+ });
+
+ fireEvent.change(screen.getByRole("textbox", { name: /youtube url/i }), {
+ target: { value: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /import youtube/i }));
+ await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /start analysis to get tonight's first cues/i,
+ );
+ expect(within(helpDialog).queryByRole("button", { name: /show the rehearsal map/i })).toBeNull();
+ });
+
+ it("shows a wait-only help state while a YouTube import is in flight", async () => {
+ let resolveImport: ((value: unknown) => void) | undefined;
+ analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true);
+ analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
+ ok: true,
+ bootstrap: bootstrap("project-local", "local-song.wav"),
+ });
+ analysisMocks.importYoutubeUrl.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveImport = resolve;
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/local-song\.wav/i)).toBeTruthy());
+
+ fireEvent.change(screen.getByRole("textbox", { name: /youtube url/i }), {
+ target: { value: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /import youtube/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /import youtube/i }).textContent).toMatch(/importing/i);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /wait for this step to finish/i,
+ );
+ expect(within(helpDialog).queryByRole("button", { name: /^start analysis$/i })).toBeNull();
+ expect(analysisMocks.startAnalysisJob).not.toHaveBeenCalled();
+
+ resolveImport?.({ ok: true, bootstrap: bootstrap("project-youtube", "imported-song.m4a") });
+ await waitFor(() => expect(screen.getByText(/imported-song\.m4a/i)).toBeTruthy());
+ });
+
+ it("switches from score back to the workspace before showing the rehearsal map", async () => {
+ analysisMocks.selectLocalAudioSource.mockResolvedValueOnce({
+ ok: true,
+ bootstrap: bootstrap("project-map", "map-song.wav"),
+ });
+ analysisMocks.startAnalysisJob.mockResolvedValueOnce({
+ jobId: "job-help-map",
+ state: "succeeded",
+ requestedAt: "2026-08-21T05:20:00.000Z",
+ updatedAt: "2026-08-21T05:20:01.000Z",
+ progressLabel: "Analysis ready",
+ progressStage: "ready",
+ progressPercent: 100,
+ cacheStatus: "disabled",
+ result: createDemoRehearsalSong(),
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
+ await waitFor(() => expect(screen.getByText(/map-song\.wav/i)).toBeTruthy());
+ fireEvent.click(screen.getByRole("button", { name: /^start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save project/i }).getAttribute("aria-disabled")).toBeNull();
+ });
+
+ fireEvent.click(screen.getAllByRole("button", { name: /^score$/i })[0]!);
+ await waitFor(() => expect(screen.getByTestId("score-view")).toBeTruthy());
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ fireEvent.click(within(helpDialog).getByRole("button", { name: /show the rehearsal map/i }));
+
+ await waitFor(() => expect(screen.queryByTestId("score-view")).toBeNull());
+ await waitFor(() => expect(screen.queryByTestId("rehearsal-help-dialog")).toBeNull());
+ expect(document.activeElement?.id).toBe("main-content");
+ });
+});
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index 3eed386f8..f9371e05f 100644
--- a/apps/desktop/src/App.test.tsx
+++ b/apps/desktop/src/App.test.tsx
@@ -218,7 +218,11 @@ describe("App", () => {
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(fireEvent.click(screen.getByRole("button", { name: /help coming soon/i }))).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Choose a local song first/i,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Close help/i }));
const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i });
const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" });
expect(activePrimaryNavButton).toHaveAttribute("aria-current", "page");
@@ -875,20 +879,14 @@ describe("App", () => {
});
});
- it("keeps handoff metadata tied to the source that produced the current result", async () => {
+ it("retires handoff export when a different source replaces the analyzed source", async () => {
const originalCreateObjectUrl = URL.createObjectURL;
- const originalRevokeObjectUrl = URL.revokeObjectURL;
const createObjectUrl = vi.fn(() => "blob:handoff");
- const revokeObjectUrl = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectUrl
});
- Object.defineProperty(URL, "revokeObjectURL", {
- configurable: true,
- value: revokeObjectUrl
- });
tauriInvoke
.mockResolvedValueOnce(bootstrapResponse())
@@ -923,24 +921,16 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy());
- fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
- const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
- const payload = JSON.parse(await blob.text());
-
- expect(payload.sourceAssets[0].fileName).toBe("late-night-set.wav");
- expect(JSON.stringify(payload)).not.toContain("next-song.wav");
- expect(click).toHaveBeenCalledTimes(1);
- expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff");
+ expect(screen.queryByRole("heading", { name: /Late Night Set/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /export handoff/i })).toBeNull();
+ expect(createObjectUrl).not.toHaveBeenCalled();
+ expect(click).not.toHaveBeenCalled();
} finally {
click.mockRestore();
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: originalCreateObjectUrl
});
- Object.defineProperty(URL, "revokeObjectURL", {
- configurable: true,
- value: originalRevokeObjectUrl
- });
}
});
@@ -1375,24 +1365,26 @@ describe("App", () => {
});
});
- it("handles saving a project failure gracefully", async () => {
+ it("keeps the loaded rehearsal map visible when saving the project fails", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render( );
- // Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});
mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));
-
- // Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
await waitFor(() => {
expect(screen.getByText(/Failed to save project: Permission denied/i)).toBeTruthy();
});
+ expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByRole("button", { name: /show the rehearsal map/i })).toBeTruthy();
});
it("ignores cancellation when saving a project with Error object", async () => {
@@ -1553,14 +1545,65 @@ describe("App", () => {
});
- it("renders Settings and Help as focusable aria-disabled controls", () => {
+ it("renders Settings as a focusable aria-disabled control and opens rehearsal help", () => {
render( );
const settingsButton = screen.getByRole("button", { name: "Settings coming soon" });
- const helpButton = screen.getByRole("button", { name: "Help coming soon" });
+ const helpButton = screen.getByRole("button", { name: "Open rehearsal help" });
expect(settingsButton).toHaveAttribute("aria-disabled", "true");
expect(settingsButton).not.toHaveAttribute("disabled");
- expect(helpButton).toHaveAttribute("aria-disabled", "true");
- expect(helpButton).not.toHaveAttribute("disabled");
+ expect(helpButton).toHaveAttribute("aria-haspopup", "dialog");
+ expect(helpButton).toHaveAttribute("aria-expanded", "false");
+ fireEvent.click(helpButton);
+ expect(helpButton).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("button", { name: /Choose a local song/i })).toBeTruthy();
+ });
+
+ it("starts local-file intake from rehearsal help before a song is loaded", async () => {
+ tauriInvoke.mockResolvedValueOnce(bootstrapResponse());
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ fireEvent.click(screen.getByRole("button", { name: /Choose a local song/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
+ });
+ });
+
+ it("starts analysis from rehearsal help after a local song is chosen", async () => {
+ tauriInvoke
+ .mockResolvedValueOnce(bootstrapResponse())
+ .mockResolvedValueOnce(jobStatusResponse({
+ jobId: "job-help-start",
+ state: "queued",
+ progressLabel: "Queued for analysis"
+ }))
+ .mockResolvedValueOnce(succeededResult());
+
+ 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.getByRole("button", { name: "Open rehearsal help" }));
+ const helpDialog = screen.getByTestId("rehearsal-help-dialog");
+ expect(within(helpDialog).getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Start analysis to get tonight's first cues/i,
+ );
+ fireEvent.click(within(helpDialog).getByRole("button", { name: /^Start analysis$/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
+ });
+ });
+
+ it("focuses tonight's rehearsal map from help after analysis is ready", async () => {
+ mockLoadProject.mockResolvedValueOnce(succeededResult().result);
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /open project/i }));
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Open rehearsal help" }));
+ fireEvent.click(screen.getByRole("button", { name: /Show the rehearsal map/i }));
+ expect(document.getElementById("main-content")).toBe(document.activeElement);
});
it("keeps the Score view disabled until a song is loaded", () => {
@@ -1616,4 +1659,4 @@ describe("App", () => {
expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
expect(screen.queryByText(/Song Timeline/i)).toBeNull();
});
-});
+});
\ No newline at end of file
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index f3d678454..ee24e2e86 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -47,6 +47,8 @@ import { createTranslator, detectPreferredLocale, type TranslationKey } from "./
import { ScoreView } from "./features/score/ScoreView";
import { Workspace } from "./features/workspace/Workspace";
import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates";
+import { RehearsalHelp } from "./features/help/RehearsalHelpDialog";
+import { resolveRehearsalHelpPhase } from "./features/help/rehearsalHelp";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
@@ -255,6 +257,8 @@ export function App() {
const [jobResult, setJobResult] = useState(null);
const [jobResultBootstrap, setJobResultBootstrap] = useState(null);
const [jobError, setJobError] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const [analysisFailed, setAnalysisFailed] = useState(false);
const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined);
const [isStarting, setIsStarting] = useState(false);
const [selectedBootstrap, setSelectedBootstrap] = useState(null);
@@ -264,10 +268,19 @@ export function App() {
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [activeView, setActiveView] = useState("workspace");
+ const [helpOpen, setHelpOpen] = useState(false);
const activeJobIdRef = useRef(null);
const youtubeInputRef = useRef(null);
const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running";
+ const helpPhase = resolveRehearsalHelpPhase({
+ hasLocalSource: selectedBootstrap !== null,
+ analysisInFlight: analysisInFlight || isStarting || isImporting,
+ // The workspace hides the map behind ErrorState while jobError is set,
+ // so help must not offer it as ready in that state.
+ hasSong: jobResult !== null && jobError === null,
+ hasError: analysisFailed,
+ });
const selectedRequest: AnalysisJobRequest = selectedBootstrap
? {
sourceKind: "local_audio",
@@ -289,9 +302,11 @@ export function App() {
setJobResultBootstrap(activeAnalysisBootstrap);
setActiveAnalysisBootstrap(null);
setJobError(null);
+ setAnalysisFailed(false);
}
if (nextStatus.state === "failed") {
setActiveAnalysisBootstrap(null);
+ setAnalysisFailed(true);
setJobError(safeErrorDetail(nextStatus.error?.message, t("analysisCouldNotStart")));
}
}, [activeAnalysisBootstrap, t]);
@@ -361,6 +376,7 @@ export function App() {
return;
}
const fallbackMessage = t("analysisCouldNotStart");
+ setAnalysisFailed(true);
setJobError(fallbackMessage);
setJobStatus({
...jobStatus,
@@ -387,8 +403,14 @@ export function App() {
/** Documented. */
const handleStartAnalysis = async () => {
+ if (analysisInFlight || isStarting || isImporting || !selectedBootstrap) {
+ return;
+ }
+
const submittedBootstrap = selectedBootstrap;
+ setAnalysisFailed(false);
setJobError(null);
+ setSaveError(null);
setJobResult(null);
setJobResultBootstrap(null);
setJobStatus(null);
@@ -407,6 +429,7 @@ export function App() {
} catch {
setJobStatus(null);
setActiveAnalysisBootstrap(null);
+ setAnalysisFailed(true);
setJobError(t("analysisCouldNotStart"));
} finally {
setIsStarting(false);
@@ -419,6 +442,13 @@ export function App() {
setSelectionErrorSource(null);
const selection = await selectLocalAudioSource();
if (selection.ok) {
+ setAnalysisFailed(false);
+ setJobError(null);
+ setSaveError(null);
+ setJobResult(null);
+ setJobResultBootstrap(null);
+ setJobStatus(null);
+ setActiveAnalysisBootstrap(null);
setSelectedBootstrap(selection.bootstrap);
return;
}
@@ -450,6 +480,13 @@ export function App() {
try {
const selection = await importYoutubeUrl(normalizedUrl);
if (selection.ok) {
+ setAnalysisFailed(false);
+ setJobError(null);
+ setSaveError(null);
+ setJobResult(null);
+ setJobResultBootstrap(null);
+ setJobStatus(null);
+ setActiveAnalysisBootstrap(null);
setSelectedBootstrap(selection.bootstrap);
setYoutubeUrl("");
} else {
@@ -477,11 +514,14 @@ export function App() {
setJobResult(song);
setJobResultBootstrap(null);
setJobError(null);
+ setSaveError(null);
+ setAnalysisFailed(false);
setSelectedBootstrap(null);
setActiveAnalysisBootstrap(null);
setJobStatus(null);
} catch (e) {
if (!isUserCancellation(e)) {
+ setAnalysisFailed(false);
setJobError(`${t("loadProjectFailedPrefix")}: ${safeErrorDetail(e, t("loadProjectFailedFallback"))}`);
}
}
@@ -489,11 +529,12 @@ export function App() {
/** Documented. */
const handleSaveProject = async () => {
+ setSaveError(null);
try {
await saveProject(jobResult!);
} catch (e) {
if (!isUserCancellation(e)) {
- setJobError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`);
+ setSaveError(`${t("saveProjectFailedPrefix")}: ${safeErrorDetail(e, t("saveProjectFailedFallback"))}`);
}
}
};
@@ -623,11 +664,12 @@ export function App() {
setHelpOpen(true)}
+ className="inline-flex items-center justify-center rounded-xl p-2 text-cyan-200 transition hover:bg-white/5 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
@@ -635,7 +677,7 @@ export function App() {
-
+
{NAV_ITEMS.map((item) => {
const { label, enabled, active, title } = navButtonState(item);
@@ -663,6 +705,18 @@ export function App() {
);
})}
+ setHelpOpen(true)}
+ className="inline-flex min-h-10 min-w-10 shrink-0 items-center justify-center rounded-xl px-3 text-cyan-200 transition hover:bg-white/5 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
+ >
+
+ {t("helpOpen")}
+
@@ -829,6 +883,12 @@ export function App() {
{selectionError}
)}
+
+ {saveError && (
+
+ {saveError}
+
+ )}
@@ -855,6 +915,26 @@ export function App() {
+ {
+ void handleChooseLocalAudio();
+ }}
+ onStartAnalysis={() => {
+ void handleStartAnalysis();
+ }}
+ onShowMap={() => {
+ setActiveView("workspace");
+ /** Focus now for synchronous callers, again after unmount resets focus to . */
+ const focusMainContent = () => {
+ document.getElementById("main-content")?.focus();
+ };
+ focusMainContent();
+ window.setTimeout(focusMainContent, 0);
+ }}
+ />
);
diff --git a/apps/desktop/src/features/help/RehearsalHelp.test.tsx b/apps/desktop/src/features/help/RehearsalHelp.test.tsx
new file mode 100644
index 000000000..e1c050f98
--- /dev/null
+++ b/apps/desktop/src/features/help/RehearsalHelp.test.tsx
@@ -0,0 +1,110 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { RehearsalHelp } from "./RehearsalHelpDialog";
+import type { RehearsalHelpPhase } from "./rehearsalHelp";
+
+const originalLanguage = navigator.language;
+
+function setNavigatorLanguage(language: string) {
+ Object.defineProperty(navigator, "language", {
+ configurable: true,
+ value: language,
+ });
+}
+
+function renderHelp(phase: RehearsalHelpPhase) {
+ const onOpenChange = vi.fn();
+ const onChooseLocal = vi.fn();
+ const onStartAnalysis = vi.fn();
+ const onShowMap = vi.fn();
+ render(
+ ,
+ );
+ return { onOpenChange, onChooseLocal, onStartAnalysis, onShowMap };
+}
+
+describe("RehearsalHelp", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ });
+
+ it("names choose-a-local-song as the first action", () => {
+ setNavigatorLanguage("en-US");
+ const handlers = renderHelp("choose-local-song");
+
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Choose a local song first/i,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Choose a local song/i }));
+ expect(handlers.onOpenChange).toHaveBeenCalledWith(false);
+ expect(handlers.onChooseLocal).toHaveBeenCalledTimes(1);
+ expect(handlers.onStartAnalysis).not.toHaveBeenCalled();
+ expect(handlers.onShowMap).not.toHaveBeenCalled();
+ });
+
+ it("starts analysis once a local file is ready", () => {
+ setNavigatorLanguage("en-US");
+ const handlers = renderHelp("start-analysis");
+
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Start analysis to get tonight's first cues/i,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /^Start analysis$/i }));
+ expect(handlers.onStartAnalysis).toHaveBeenCalledTimes(1);
+ expect(handlers.onChooseLocal).not.toHaveBeenCalled();
+ });
+
+ it("waits without a competing action while analysis runs", () => {
+ setNavigatorLanguage("en-US");
+ renderHelp("wait-for-analysis");
+
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Analysis is running/i,
+ );
+ expect(screen.queryByRole("button", { name: /Choose a local song/i })).toBeNull();
+ expect(screen.queryByRole("button", { name: /^Start analysis$/i })).toBeNull();
+ expect(screen.getByRole("button", { name: /Close help/i })).toBeTruthy();
+ });
+
+ it("retries with another local song after a failed analysis", () => {
+ setNavigatorLanguage("en-US");
+ const handlers = renderHelp("retry-after-failure");
+
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /Choose another local song and try again/i,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Choose another song/i }));
+ expect(handlers.onChooseLocal).toHaveBeenCalledTimes(1);
+ expect(handlers.onStartAnalysis).not.toHaveBeenCalled();
+ });
+
+ it("shows the rehearsal map once tonight's analysis is ready", () => {
+ setNavigatorLanguage("en-US");
+ const handlers = renderHelp("open-rehearsal-map");
+
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /rehearsal map is ready/i,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /Show the rehearsal map/i }));
+ expect(handlers.onShowMap).toHaveBeenCalledTimes(1);
+ expect(handlers.onChooseLocal).not.toHaveBeenCalled();
+ });
+
+ it("keeps Korean next-action copy on the same help surface", () => {
+ setNavigatorLanguage("ko-KR");
+ renderHelp("choose-local-song");
+
+ expect(screen.getByRole("heading", { name: /오늘 밤 BandScope가 돕는 방법/ })).toBeTruthy();
+ expect(screen.getByTestId("rehearsal-help-next-action").textContent).toMatch(
+ /먼저 로컬 곡을 고르세요/,
+ );
+ expect(screen.getByRole("button", { name: /로컬 곡 고르기/ })).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/features/help/RehearsalHelpDialog.tsx b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx
new file mode 100644
index 000000000..81d1cc835
--- /dev/null
+++ b/apps/desktop/src/features/help/RehearsalHelpDialog.tsx
@@ -0,0 +1,132 @@
+import { useMemo, useRef, type ReactElement } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ createTranslator,
+ detectPreferredLocale,
+ type TranslationKey,
+} from "../../i18n";
+import {
+ rehearsalHelpAction,
+ type RehearsalHelpPhase,
+} from "./rehearsalHelp";
+
+interface RehearsalHelpProps {
+ open: boolean;
+ phase: RehearsalHelpPhase;
+ onOpenChange: (open: boolean) => void;
+ onChooseLocal: () => void;
+ onStartAnalysis: () => void;
+ onShowMap: () => void;
+}
+
+const PHASE_BODY_KEY: Record = {
+ "choose-local-song": "helpChooseLocalBody",
+ "start-analysis": "helpStartAnalysisBody",
+ "wait-for-analysis": "helpWaitBody",
+ "retry-after-failure": "helpRetryBody",
+ "open-rehearsal-map": "helpReadyBody",
+};
+
+const PHASE_ACTION_KEY: Record<
+ Exclude, "none">,
+ TranslationKey
+> = {
+ "choose-local": "helpChooseLocalAction",
+ "start-analysis": "helpStartAnalysisAction",
+ "focus-map": "helpReadyAction",
+};
+
+/** Render tonight's rehearsal help with one next action. */
+export function RehearsalHelp({
+ open,
+ phase,
+ onOpenChange,
+ onChooseLocal,
+ onStartAnalysis,
+ onShowMap,
+}: RehearsalHelpProps): ReactElement {
+ const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const focusMapOnCloseRef = useRef(false);
+ const action = rehearsalHelpAction(phase);
+ const retry = phase === "retry-after-failure";
+
+ /** Preserve Base UI's normal focus restoration except for the explicit map action. */
+ const finalFocusTarget = (): true | HTMLElement => {
+ if (!focusMapOnCloseRef.current) {
+ return true;
+ }
+ focusMapOnCloseRef.current = false;
+ return document.getElementById("main-content") ?? true;
+ };
+
+ /** Documented. */
+ const runNextAction = (): void => {
+ focusMapOnCloseRef.current = action === "focus-map";
+ onOpenChange(false);
+ if (action === "choose-local") {
+ onChooseLocal();
+ return;
+ }
+ if (action === "start-analysis") {
+ onStartAnalysis();
+ return;
+ }
+ if (action === "focus-map") {
+ onShowMap();
+ }
+ };
+
+ return (
+
+
+
+
+ {t("helpTitle")}
+
+
+ {t(PHASE_BODY_KEY[phase])}
+
+
+ {t("helpPrivacy")}
+
+
+ {t("helpClose")}
+
+ }
+ />
+ {action !== "none" ? (
+
+ {retry ? t("helpRetryAction") : t(PHASE_ACTION_KEY[action])}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/features/help/rehearsalHelp.test.ts b/apps/desktop/src/features/help/rehearsalHelp.test.ts
new file mode 100644
index 000000000..78b6441ae
--- /dev/null
+++ b/apps/desktop/src/features/help/rehearsalHelp.test.ts
@@ -0,0 +1,93 @@
+import { readdirSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ rehearsalHelpAction,
+ resolveRehearsalHelpPhase,
+ type RehearsalHelpSnapshot,
+} from "./rehearsalHelp";
+
+function snapshot(
+ overrides: Partial = {},
+): RehearsalHelpSnapshot {
+ return {
+ hasLocalSource: false,
+ analysisInFlight: false,
+ hasSong: false,
+ hasError: false,
+ ...overrides,
+ };
+}
+
+/**
+ * Security Notes: this test-only helper reads the fixed
+ * process.cwd()/src/features/help directory. It accepts no user-provided path
+ * and must not become a general-purpose file-reading API.
+ */
+function productionHelpModuleNames(): string[] {
+ return readdirSync(join(process.cwd(), "src", "features", "help"))
+ .filter(
+ (name) => /\.(?:ts|tsx)$/.test(name) && !name.includes(".test."),
+ )
+ .map((name) => name.replace(/\.(?:ts|tsx)$/, ""));
+}
+
+describe("rehearsalHelp", () => {
+ it("keeps production module basenames unique on case-insensitive filesystems", () => {
+ const moduleNames = productionHelpModuleNames();
+ const caseFoldedNames = moduleNames.map((name) => name.toLowerCase());
+
+ expect(new Set(caseFoldedNames).size).toBe(moduleNames.length);
+ });
+
+ it("asks for a local song before any source is chosen", () => {
+ expect(resolveRehearsalHelpPhase(snapshot())).toBe("choose-local-song");
+ expect(rehearsalHelpAction("choose-local-song")).toBe("choose-local");
+ });
+
+ it("starts analysis once a local song is loaded", () => {
+ expect(
+ resolveRehearsalHelpPhase(snapshot({ hasLocalSource: true })),
+ ).toBe("start-analysis");
+ expect(rehearsalHelpAction("start-analysis")).toBe("start-analysis");
+ });
+
+ it("waits while analysis is in flight even if an earlier error remains", () => {
+ expect(
+ resolveRehearsalHelpPhase(
+ snapshot({
+ hasLocalSource: true,
+ analysisInFlight: true,
+ hasError: true,
+ }),
+ ),
+ ).toBe("wait-for-analysis");
+ expect(rehearsalHelpAction("wait-for-analysis")).toBe("none");
+ });
+
+ it("retries after a failed analysis when no song is ready", () => {
+ expect(
+ resolveRehearsalHelpPhase(
+ snapshot({ hasLocalSource: true, hasError: true }),
+ ),
+ ).toBe("retry-after-failure");
+ expect(rehearsalHelpAction("retry-after-failure")).toBe("choose-local");
+ });
+
+ it("opens the rehearsal map once a song is ready", () => {
+ expect(
+ resolveRehearsalHelpPhase(
+ snapshot({ hasLocalSource: true, hasSong: true }),
+ ),
+ ).toBe("open-rehearsal-map");
+ expect(rehearsalHelpAction("open-rehearsal-map")).toBe("focus-map");
+ });
+
+ it("keeps a ready song ahead of a stale error flag", () => {
+ expect(
+ resolveRehearsalHelpPhase(
+ snapshot({ hasLocalSource: true, hasSong: true, hasError: true }),
+ ),
+ ).toBe("open-rehearsal-map");
+ });
+});
diff --git a/apps/desktop/src/features/help/rehearsalHelp.ts b/apps/desktop/src/features/help/rehearsalHelp.ts
new file mode 100644
index 000000000..ca9439372
--- /dev/null
+++ b/apps/desktop/src/features/help/rehearsalHelp.ts
@@ -0,0 +1,54 @@
+/** Tonight's rehearsal-help phases. Each names one next action. */
+export type RehearsalHelpPhase =
+ | "choose-local-song"
+ | "start-analysis"
+ | "wait-for-analysis"
+ | "retry-after-failure"
+ | "open-rehearsal-map";
+
+/** Discrete help-button actions that reuse existing App handlers. */
+export type RehearsalHelpAction = "choose-local" | "start-analysis" | "focus-map" | "none";
+
+/** Observable App state used to choose the help next-action. */
+export interface RehearsalHelpSnapshot {
+ hasLocalSource: boolean;
+ analysisInFlight: boolean;
+ hasSong: boolean;
+ hasError: boolean;
+}
+
+/** Resolve the single next rehearsal action from current App state. */
+export function resolveRehearsalHelpPhase(
+ snapshot: RehearsalHelpSnapshot,
+): RehearsalHelpPhase {
+ if (snapshot.analysisInFlight) {
+ return "wait-for-analysis";
+ }
+ if (snapshot.hasError && !snapshot.hasSong) {
+ return "retry-after-failure";
+ }
+ if (snapshot.hasSong) {
+ return "open-rehearsal-map";
+ }
+ if (snapshot.hasLocalSource) {
+ return "start-analysis";
+ }
+ return "choose-local-song";
+}
+
+/** Map a help phase to the App handler it should invoke. */
+export function rehearsalHelpAction(
+ phase: RehearsalHelpPhase,
+): RehearsalHelpAction {
+ switch (phase) {
+ case "choose-local-song":
+ case "retry-after-failure":
+ return "choose-local";
+ case "start-analysis":
+ return "start-analysis";
+ case "open-rehearsal-map":
+ return "focus-map";
+ case "wait-for-analysis":
+ return "none";
+ }
+}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..3f4f239f1 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -109,7 +109,19 @@
"compactViewSuffix": "compact view",
"comingSoon": "Coming soon",
"settingsComingSoon": "Settings coming soon",
- "helpComingSoon": "Help coming soon",
+ "helpOpen": "Open rehearsal help",
+ "helpTitle": "How BandScope helps tonight",
+ "helpPrivacy": "Audio and project files stay on this computer.",
+ "helpClose": "Close help",
+ "helpChooseLocalBody": "Choose a local song first. Analysis stays on this computer.",
+ "helpChooseLocalAction": "Choose a local song",
+ "helpStartAnalysisBody": "The file is ready. Start analysis to get tonight's first cues.",
+ "helpStartAnalysisAction": "Start analysis",
+ "helpWaitBody": "Analysis is running. Wait for this step to finish and stay on this screen until the rehearsal map appears.",
+ "helpRetryBody": "That analysis did not finish. Choose another local song and try again.",
+ "helpRetryAction": "Choose another song",
+ "helpReadyBody": "Tonight's rehearsal map is ready. Start with the first priority section, then export a cue sheet for the room.",
+ "helpReadyAction": "Show the rehearsal map",
"localFirst": "Local-first",
"localFirstDetail": "Your rehearsal map stays on this device. Project files stay local. YouTube only leaves the app when you choose import.",
"sourceControlsAriaLabel": "Source controls",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..4e0c0b6ed 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -110,6 +110,19 @@
"comingSoon": "곧 제공됩니다",
"settingsComingSoon": "설정은 곧 제공됩니다",
"helpComingSoon": "도움말은 곧 제공됩니다",
+ "helpOpen": "합주 도움말 열기",
+ "helpTitle": "오늘 밤 BandScope가 돕는 방법",
+ "helpPrivacy": "오디오와 프로젝트 파일은 이 컴퓨터에 머뭅니다.",
+ "helpClose": "도움말 닫기",
+ "helpChooseLocalBody": "먼저 로컬 곡을 고르세요. 분석은 이 컴퓨터에서만 이뤄집니다.",
+ "helpChooseLocalAction": "로컬 곡 고르기",
+ "helpStartAnalysisBody": "파일이 준비됐습니다. 분석을 시작해 오늘 밤 첫 큐를 받으세요.",
+ "helpStartAnalysisAction": "분석 시작",
+ "helpWaitBody": "분석을 진행 중입니다. 이 단계가 끝날 때까지 기다리고, 합주 지도가 나올 때까지 이 화면에 머무르세요.",
+ "helpRetryBody": "분석이 끝나지 않았습니다. 다른 로컬 곡을 고르고 다시 시도하세요.",
+ "helpRetryAction": "다른 곡 고르기",
+ "helpReadyBody": "오늘 밤 합주 지도가 준비됐습니다. 우선 구간부터 보고, 큐시트를 내보내 방에 나눠 주세요.",
+ "helpReadyAction": "합주 지도 보기",
"localFirst": "로컬 우선",
"localFirstDetail": "합주 지도는 이 기기에 머뭅니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.",
"sourceControlsAriaLabel": "소스 컨트롤",
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index f1db6f2b8..e22e7db63 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -23,6 +23,8 @@ export default defineConfig({
"src/App.tsx",
"src/lib/export.ts",
"src/i18n/index.ts",
+ "src/features/help/rehearsalHelp.ts",
+ "src/features/help/RehearsalHelpDialog.tsx",
"src/features/score/ScoreViewer.tsx",
"src/features/score/ScoreView.tsx",
"src/features/score/scoreStorage.ts"