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() { @@ -635,7 +677,7 @@ export function App() { -
+
@@ -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" ? ( + + ) : 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"