diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..5babb1a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- When YouTube import fails, customer-facing copy must name Paste another YouTube link as the next rehearsal action. The field-level message stays redacted; the recovery action only focuses the existing YouTube field. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..1176679c5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Failed YouTube import names Paste another YouTube link as the next action. Buyer-visible copy stays redacted and does not retry the network from the recovery card. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..3974db067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- When a YouTube link cannot be imported, the workspace names Paste another YouTube link as the next rehearsal action and keeps the failed URL ready to replace. - 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..07f377f5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. -Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. +When YouTube import fails, the workspace must name Paste another YouTube link as the next action. The recovery action only focuses the YouTube field. + +Agent execution and delegation rules live in `docs/agents/README.md`. PR continuity and duplicate-handling policy live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..5b7a48b24 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1162,11 +1162,13 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); - expect(alert).toHaveAttribute("id", "selection-error"); + const fieldError = document.getElementById("selection-error"); + expect(fieldError).toHaveTextContent(/This video is age restricted/i); + expect(fieldError).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); - expect(input).toHaveAttribute("aria-describedby", alert.id); + expect(input).toHaveAttribute("aria-describedby", "selection-error"); + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1182,7 +1184,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Network Error/i)).toBeTruthy(); + expect(screen.getAllByText(/Network Error/i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1197,7 +1200,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1209,7 +1213,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1221,7 +1226,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1233,7 +1239,8 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); @@ -1246,7 +1253,8 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); @@ -1259,11 +1267,63 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); expect(tauriInvoke).not.toHaveBeenCalled(); }); + it("focuses the YouTube field from the import-failure next action", async () => { + render(); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + fireEvent.change(input, { target: { value: "not-a-url" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Paste another YouTube link" })); + + expect(document.activeElement).toBe(input); + expect(input).toHaveValue("not-a-url"); + }); + + it("redacts URL-shaped YouTube import diagnostics before they become buyer copy", async () => { + tauriInvoke.mockRejectedValueOnce( + new Error("download failed: https://youtube.com/watch?v=secret-token") + ); + + render(); + const input = screen.getByPlaceholderText(/YouTube URL.../i); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "That YouTube link can't start tonight" })).toBeTruthy(); + }); + expect(document.getElementById("selection-error")?.textContent).toMatch(/download failed: \[link\]/i); + expect(screen.queryByText(/secret-token/i)).toBeNull(); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); + }); + + it("localizes the YouTube import-failure next action", async () => { + const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); + + try { + render(); + const input = screen.getByRole("textbox", { name: /유튜브 URL/i }); + fireEvent.change(input, { target: { value: "not-a-url" } }); + fireEvent.click(screen.getByRole("button", { name: /유튜브 가져오기/i })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "그 유튜브 링크로는 오늘 합주를 시작할 수 없습니다" })).toBeTruthy(); + }); + expect(screen.getByRole("button", { name: "다른 유튜브 링크 붙여넣기" })).toBeTruthy(); + } finally { + languageSpy.mockRestore(); + } + }); + it("loads a project and updates the UI", async () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); @@ -1523,7 +1583,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); @@ -1548,7 +1609,8 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getAllByText(/Failed to import YouTube URL./i).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Paste another YouTube link" })).toBeTruthy(); }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..2d7196d5d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -388,6 +388,8 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { const submittedBootstrap = selectedBootstrap; + setSelectionError(null); + setSelectionErrorSource(null); setJobError(null); setJobResult(null); setJobResultBootstrap(null); @@ -429,8 +431,21 @@ export function App() { setJobStatus(null); }; - /** Documented. */ + /** + * Import the current YouTube URL through BandScope's existing downloader boundary. + * + * Security Notes: + * - The URL field and bridge failure detail are untrusted input. + * - URL admission remains fail-closed through `isSupportedYoutubeUrl` before the downloader is invoked. + * - Buyer-visible failures pass through `safeErrorDetail`, which removes URL, local-path, and secret-shaped details. + * - The recovery action only focuses/selects the existing field; it never performs a second network request. + */ const handleImportYoutube = async () => { + if (isImporting) { + return; + } + + setJobError(null); setSelectionError(null); setSelectionErrorSource(null); const normalizedUrl = youtubeUrl.trim(); @@ -456,24 +471,43 @@ export function App() { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); setSelectionErrorSource("youtube"); } - } catch { - setSelectionError(t("youtubeImportFailed")); + } catch (error) { + const fallback = t("youtubeImportFailed"); + const detail = safeErrorDetail(error, fallback); + setSelectionError(detail === fallback ? fallback : `${fallback} ${detail}`); setSelectionErrorSource("youtube"); } finally { setIsImporting(false); } }; + /** Focus the YouTube field so the next paste replaces the failed link. */ + const handlePasteAnotherYoutubeLink = () => { + youtubeInputRef.current?.focus(); + youtubeInputRef.current?.select(); + }; + + /** Clear only the YouTube-owned recovery state, preserving unrelated source errors. */ + const dismissYoutubeSelectionError = () => { + if (selectionErrorSource === "youtube") { + setSelectionError(null); + setSelectionErrorSource(null); + } + }; + /** Documented. */ const handleClearYoutubeUrl = () => { youtubeInputRef.current?.focus(); setYoutubeUrl(""); + dismissYoutubeSelectionError(); }; /** Documented. */ const handleLoadProject = async () => { try { const song = await loadProject(); + setSelectionError(null); + setSelectionErrorSource(null); setJobResult(song); setJobResultBootstrap(null); setJobError(null); @@ -511,6 +545,18 @@ export function App() { if (analysisInFlight || isStarting) { return ; } + if (selectionError && selectionErrorSource === "youtube") { + return ( + + ); + } if (jobResult) { return ; } @@ -518,6 +564,7 @@ export function App() { }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const youtubeRecoveryVisible = Boolean(selectionError && selectionErrorSource === "youtube"); /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { @@ -701,7 +748,10 @@ export function App() { placeholder={t("youtubePlaceholder")} value={youtubeUrl} maxLength={MAX_YOUTUBE_URL_LENGTH} - onChange={(e) => setYoutubeUrl(e.target.value)} + onChange={(e) => { + setYoutubeUrl(e.target.value); + dismissYoutubeSelectionError(); + }} disabled={analysisInFlight || isStarting || isImporting} className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" aria-label={t("youtubeUrlAriaLabel")} @@ -737,7 +787,7 @@ export function App() {
+ )} ); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..72c7b322b 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -93,6 +93,9 @@ "importYoutube": "Import YouTube", "importingYoutube": "Importing...", "youtubeImportFailed": "Failed to import YouTube URL.", + "youtubeImportFailureTitle": "That YouTube link can't start tonight", + "youtubeImportFailureGuidance": "Paste a standard YouTube watch, Shorts, or youtu.be link. After import, BandScope keeps the audio on this device.", + "pasteAnotherYoutubeLink": "Paste another YouTube link", "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..2206216bd 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -93,6 +93,9 @@ "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", + "youtubeImportFailureTitle": "그 유튜브 링크로는 오늘 합주를 시작할 수 없습니다", + "youtubeImportFailureGuidance": "표준 YouTube watch, Shorts 또는 youtu.be 링크를 붙여넣으세요. 가져온 뒤에는 오디오가 이 기기에만 남습니다.", + "pasteAnotherYoutubeLink": "다른 유튜브 링크 붙여넣기", "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간",