From 95e74ee869d5b0f461bd2a7775475f839f0a09d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 05:14:26 +0000 Subject: [PATCH 1/9] feat(workspace): guide tonight's first pre-chorus on map and player Name the earliest labeled pre-chorus so the room can play the lift into the chorus. Workspace Open scrolls the matching map section; the player Hear action exists only when playback can seek. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 5 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 103 ++++++++ apps/desktop/src/features/player/index.tsx | 70 ++++- ...stPreChorusCallout.reduced-motion.test.tsx | 67 +++++ .../workspace/FirstPreChorusCallout.test.tsx | 201 +++++++++++++++ .../workspace/FirstPreChorusCallout.tsx | 151 +++++++++++ .../src/features/workspace/Workspace.test.tsx | 46 ++++ .../src/features/workspace/Workspace.tsx | 3 + .../features/workspace/firstPreChorus.test.ts | 240 ++++++++++++++++++ .../src/features/workspace/firstPreChorus.ts | 192 ++++++++++++++ apps/desktop/src/i18n/index.test.ts | 18 +- apps/desktop/src/i18n/index.ts | 17 ++ apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-prechorus-navigation.md | 14 + 19 files changed, 1144 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPreChorusCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPreChorusCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstPreChorus.test.ts create mode 100644 apps/desktop/src/features/workspace/firstPreChorus.ts create mode 100644 docs/doctoring/reduced-motion-first-prechorus-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..a9260fc30 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. +- Name tonight's first labeled pre-chorus with the holding part when an active role is corroborated, the labeled lift, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, 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 3302a6fc3..704fa85fe 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. +- Workspace and player copy for tonight's first labeled pre-chorus must name the holding part when corroborated, the labeled lift, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..ff6a4a817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ ### Added +- Name tonight's first labeled pre-chorus on the workspace and player so the room can play the lift into the chorus; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Reset first-pre-chorus action completion when the runtime song object changes, so a replacement song cannot inherit success-shaped Open/Hear guidance merely because both songs have malformed or missing ids and the same pre-chorus metadata. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..5650b1001 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). `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). Workspace and player name tonight's first labeled pre-chorus so the room can play the lift into the chorus; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. `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/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..b2db22f88 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +function songWithPreChorus() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + preChorus.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + preChorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [preChorus]; + return song; +} + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first pre-chorus from this player.") + ).toBeTruthy(); + }); + + it("keeps the pre-chorus hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal pre-chorus at 0:20" })).toBeNull(); + expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy(); + }); + + it("delegates the pre-chorus hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pre-chorus at 0:20" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(20); + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithPreChorus(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect( + screen.getByText("No pre-chorus yet. Stay on tonight's map until the first lift is labeled.") + ).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithPreChorus(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + + render(); + + expect( + screen.getByText("No pre-chorus yet. Stay on tonight's map until the first lift is labeled.") + ).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithPreChorus(); + song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("pre-chorus")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithPreChorus(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..ee8bab502 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,46 +1,96 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstPreChorusCallout } from "../workspace/FirstPreChorusCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Return whether one runtime section is safe to summarize in the player. */ +function isPlayerSummarySection(value: unknown): value is RehearsalSection { + if (value === null || typeof value !== "object") { + return false; + } + const section = value as Partial; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) + ); +} + +/** Return dense, individually valid sections without trusting runtime collection metadata. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = song.sections as unknown; + if (!Array.isArray(sections)) { + return []; + } + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + for (let index = 0; index < length; index += 1) { + if (!(index in sections)) { + return []; + } + } + return sections.filter(isPlayerSummarySection); +} + +/** Player surface that names tonight's first labeled pre-chorus and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

No song loaded. Start an analysis to use the player.

+

{t("firstPreChorusNeedsSong")}

); } + const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; + return (

{title}

+
- {song.title} + {songTitle} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section) => ( + {sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstPreChorusCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstPreChorusCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..04d9b913c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPreChorusCallout.reduced-motion.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPreChorusCallout } from "./FirstPreChorusCallout"; + +function songWithPreChorus() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + preChorus.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + preChorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [preChorus]; + return song; +} + +describe("FirstPreChorusCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPreChorusCallout.test.tsx b/apps/desktop/src/features/workspace/FirstPreChorusCallout.test.tsx new file mode 100644 index 000000000..0fb358f42 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPreChorusCallout.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPreChorusCallout } from "./FirstPreChorusCallout"; + +function songWithPreChorus() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + preChorus.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + preChorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [preChorus]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstPreChorusCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the first pre-chorus as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pre-chorus at 0:20" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + + expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy(); + expect( + screen.queryByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearPreChorus = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + expect(onHearPreChorus).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithPreChorus(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first pre-chorus changes or returns later", () => { + const initialSong = songWithPreChorus(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + expect( + screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeTruthy(); + + const nextSong = songWithPreChorus(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 48, end: 56 }; + rerender(); + expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:48.")).toBeTruthy(); + + grid.remove(); + }); + + it("does not carry completed guidance into a replacement song with an invalid runtime id", () => { + const firstSong = songWithPreChorus(); + const replacementSong = songWithPreChorus(); + (firstSong as unknown as { id: unknown }).id = null; + (replacementSong as unknown as { id: unknown }).id = null; + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" })); + expect( + screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy(); + expect( + screen.queryByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeNull(); + + grid.remove(); + }); + + it("keeps an unavailable pre-chorus guidance-only", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.label = "chorus"; + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No pre-chorus yet. Stay on tonight's map until the first lift is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide lift when no part holds the pre-chorus", () => { + const song = songWithPreChorus(); + song.sections[0]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first pre-chorus at 0:20" })).toBeTruthy(); + expect(screen.getByText("The band carries the pre-chorus at 0:20.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearPreChorus = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pre-chorus at 0:20" })); + expect(onHearPreChorus).toHaveBeenCalledWith(20); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy(); + }); + + it("localizes the pre-chorus form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithPreChorus(); + song.sections[0]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("리드 보컬이 0:20 프리코러스에서 리프트를 잡습니다.")).toBeTruthy(); + expect(screen.queryByText(/pre-chorus에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx b/apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx new file mode 100644 index 000000000..ad6f9cc27 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatPreChorusTime, resolveFirstPreChorus } from "./firstPreChorus"; + +/** Props for the first-pre-chorus rehearsal callout. */ +export interface FirstPreChorusCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearPreChorus?: (atSeconds: number) => void; +} + +type PreChorusCopyValues = Readonly>; + +type HeardPreChorus = Readonly<{ + song: RehearsalSong; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate pre-chorus placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatPreChorusCopy(template: string, values: PreChorusCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof PreChorusCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredPreChorusScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled pre-chorus and offer only an action that the current surface can execute. */ +export function FirstPreChorusCallout({ + song, + actionMode = "workspace-scroll", + onHearPreChorus +}: FirstPreChorusCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const preChorus = resolveFirstPreChorus(song); + const preChorusSectionIndex = + preChorus && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(preChorus.section) + : -1; + const [heardPreChorus, setHeardPreChorus] = useState(null); + + useEffect(() => { + setHeardPreChorus(null); + }, [song, preChorusSectionIndex, preChorus?.section.id, preChorus?.holdingRole?.id, preChorus?.atSeconds]); + + if (!preChorus) { + return ( + + ); + } + + const heard = + heardPreChorus?.song === song && + heardPreChorus.sectionId === preChorus.section.id && + heardPreChorus.sectionIndex === preChorusSectionIndex && + heardPreChorus.holdingRoleId === (preChorus.holdingRole?.id ?? null) && + heardPreChorus.atSeconds === preChorus.atSeconds; + const at = formatPreChorusTime(preChorus.atSeconds); + const copyValues: PreChorusCopyValues = { + role: preChorus.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, preChorus.section.label), + at + }; + const hasRole = preChorus.holdingRole !== null; + const actionLabel = formatPreChorusCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstPreChorusAction" + : "firstPreChorusActionBand" + : hasRole + ? "firstPreChorusOpenAction" + : "firstPreChorusOpenActionBand" + ), + copyValues + ); + const body = formatPreChorusCopy(t(hasRole ? "firstPreChorusBody" : "firstPreChorusBodyBand"), copyValues); + const armed = formatPreChorusCopy(t(hasRole ? "firstPreChorusArmed" : "firstPreChorusArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearPreChorus === "function"; + /** Record completion only after the owning surface has executed the selected pre-chorus action. */ + const markPreChorusActionComplete = () => { + setHeardPreChorus({ + song, + sectionId: preChorus.section.id, + sectionIndex: preChorusSectionIndex, + holdingRoleId: preChorus.holdingRole?.id ?? null, + atSeconds: preChorus.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..48293425a 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,50 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first pre-chorus as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + preChorus.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + preChorus.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [preChorus]; + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pre-chorus at 0:20" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..4758d2891 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstPreChorusCallout } from "./FirstPreChorusCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstPreChorus.test.ts b/apps/desktop/src/features/workspace/firstPreChorus.test.ts new file mode 100644 index 000000000..05f59c952 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPreChorus.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatPreChorusTime, resolveFirstPreChorus } from "./firstPreChorus"; + +function withPreChorusSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = overrides.id ?? "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: overrides.start ?? 20, end: overrides.end ?? 28 }; + const roleId = overrides.roleId ?? "lead-vocal"; + preChorus.roles = [ + { + ...seed.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + preChorus.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [preChorus]; + return song; +} + +describe("resolveFirstPreChorus", () => { + it("does not invent a pre-chorus from the demo verse", () => { + expect(resolveFirstPreChorus(createDemoRehearsalSong())).toBeNull(); + expect(formatPreChorusTime(Number.NaN)).toBe("0:00"); + expect(formatPreChorusTime(-4)).toBe("0:00"); + }); + + it("does not invent a pre-chorus from an intro, verse, chorus, bridge, outro, tag, pickup, stop, or handoff", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const intro = structuredClone(seed); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + const verse = structuredClone(seed); + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 20 }; + const chorus = structuredClone(seed); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + const bridge = structuredClone(seed); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 64, end: 80 }; + const outro = structuredClone(seed); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 90, end: 102 }; + const tag = structuredClone(seed); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 102, end: 108 }; + const pickup = structuredClone(seed); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + const stop = structuredClone(seed); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const handoff = structuredClone(seed); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [intro, pickup, stop, verse, chorus, handoff, bridge, outro, tag]; + + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("picks the earliest labeled pre-chorus and the part that carries the lift", () => { + const song = withPreChorusSection({ start: 20, end: 28 }); + const first = resolveFirstPreChorus(song); + + expect(first?.section.id).toBe("pre-chorus-1"); + expect(first?.holdingRole?.id).toBe("lead-vocal"); + expect(first?.atSeconds).toBe(20); + expect(formatPreChorusTime(first?.atSeconds ?? -1)).toBe("0:20"); + }); + + it("prefers the earlier of two labeled pre-choruses", () => { + const song = withPreChorusSection({ id: "pre-chorus-late", start: 48, end: 56 }); + const seed = song.sections[0]!; + const earlier = structuredClone(seed); + earlier.id = "pre-chorus-early"; + earlier.timeRange = { start: 20, end: 28 }; + earlier.roles = [ + { + ...seed.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[0]!, earlier]; + + const first = resolveFirstPreChorus(song); + expect(first?.section.id).toBe("pre-chorus-early"); + expect(first?.holdingRole?.id).toBe("bass-guitar"); + expect(first?.atSeconds).toBe(20); + }); + + it("keeps a band-wide lift when no active ranked role holds it", () => { + const song = withPreChorusSection({ isActive: false }); + const first = resolveFirstPreChorus(song); + expect(first?.section.id).toBe("pre-chorus-1"); + expect(first?.holdingRole).toBeNull(); + expect(first?.atSeconds).toBe(20); + }); + + it("skips a pre-chorus whose rehearsal window is unbounded", () => { + const song = withPreChorusSection({ start: Number.NaN, end: 28 }); + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("skips a pre-chorus whose end precedes its start", () => { + const song = withPreChorusSection({ start: 28, end: 20 }); + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("skips a zero-length pre-chorus window", () => { + const song = withPreChorusSection({ start: 20, end: 20 }); + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("skips a pre-chorus whose endpoint overflows the shared timing bound", () => { + const song = withPreChorusSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }); + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstPreChorus(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withPreChorusSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("keeps the lift band-wide when role identities are duplicated", () => { + const song = withPreChorusSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const first = resolveFirstPreChorus(song); + expect(first?.section.id).toBe("pre-chorus-1"); + expect(first?.holdingRole).toBeNull(); + }); + + it("tie-breaks same-start pre-choruses by locale-independent id order", () => { + const song = withPreChorusSection({ id: "pre-chorus-z", start: 20, end: 28 }); + const earlierId = structuredClone(song.sections[0]!); + earlierId.id = "pre-chorus-a"; + earlierId.roles = [ + { + ...song.sections[0]!.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlierId.partGraph = [ + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [song.sections[0]!, earlierId]; + const first = resolveFirstPreChorus(song); + expect(first?.section.id).toBe("pre-chorus-a"); + expect(first?.holdingRole?.id).toBe("bass-guitar"); + }); + + it("skips a pre-chorus whose time range is missing", () => { + const song = withPreChorusSection(); + (song.sections[0] as unknown as { timeRange: unknown }).timeRange = null; + expect(resolveFirstPreChorus(song)).toBeNull(); + }); + + it("keeps the lift band-wide when roles or graph nodes are not dense objects", () => { + const song = withPreChorusSection(); + (song.sections[0] as unknown as { roles: unknown }).roles = null; + expect(resolveFirstPreChorus(song)?.holdingRole).toBeNull(); + + const graphSong = withPreChorusSection(); + graphSong.sections[0]!.roles = [ + { + ...graphSong.sections[0]!.roles[0]!, + id: " ", + name: "", + rehearsalPriority: "urgent" as never + } + ]; + graphSong.sections[0]!.partGraph = [ + { role_id: " ", is_active: false, handoff_to: [], handoff_from: [] } as never + ]; + expect(resolveFirstPreChorus(graphSong)?.holdingRole).toBeNull(); + }); + + it("formats minute-bounded rehearsal times", () => { + expect(formatPreChorusTime(70)).toBe("1:10"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPreChorus.ts b/apps/desktop/src/features/workspace/firstPreChorus.ts new file mode 100644 index 000000000..27a19bb71 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPreChorus.ts @@ -0,0 +1,192 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first labeled pre-chorus: the earliest lift and the part that carries it. */ +export type FirstPreChorus = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative pre-chorus time as m:ss for rehearsal copy. */ +export function formatPreChorusTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return whether every numeric index is present in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!(index in value)) { + return false; + } + } + return true; +} + +/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + typeof role.name === "string" && + role.name.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the highest-priority ranked role, then a locale-independent stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the first labeled pre-chorus, or null when no safe lift remains. */ +export function resolveFirstPreChorus(song: RehearsalSong): FirstPreChorus | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const preChorusSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "pre-chorus" && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = preChorusSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..f814bead9 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,20 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes a pre-chorus label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("preserves unlabeled form values as data", () => { + expect(translateSectionFormLabel("ko", "intro")).toBe("intro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..caed9f580 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,13 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + en: { "pre-chorus": "pre-chorus" }, + ko: { "pre-chorus": "프리코러스" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,15 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for an own section-form entry, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale]; + if (!Object.prototype.hasOwnProperty.call(labels, label)) { + return label; + } + return labels[label] as string; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..3841a1c86 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstPreChorusLabel": "Tonight's first pre-chorus", + "firstPreChorusAction": "Hear {role} pre-chorus at {at}", + "firstPreChorusActionBand": "Hear the first pre-chorus at {at}", + "firstPreChorusOpenAction": "Open {role} pre-chorus at {at}", + "firstPreChorusOpenActionBand": "Open the first pre-chorus at {at}", + "firstPreChorusBody": "{role} carries the {section} at {at}.", + "firstPreChorusBodyBand": "The band carries the {section} at {at}.", + "firstPreChorusArmed": "Learn {role}'s pre-chorus at {at}. Play the lift into the chorus.", + "firstPreChorusArmedBand": "Learn the pre-chorus at {at}. Play the lift into the chorus.", + "firstPreChorusUnavailable": "No pre-chorus yet. Stay on tonight's map until the first lift is labeled.", + "firstPreChorusNeedsSong": "Analyze tonight's song first, then hear the first pre-chorus from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..d48e7589d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstPreChorusLabel": "오늘 첫 프리코러스", + "firstPreChorusAction": "{at}에 {role} 프리코러스 듣기", + "firstPreChorusActionBand": "{at} 첫 프리코러스 듣기", + "firstPreChorusOpenAction": "{at} {role} 프리코러스 위치 열기", + "firstPreChorusOpenActionBand": "{at} 첫 프리코러스 위치 열기", + "firstPreChorusBody": "{role}이 {at} {section}에서 리프트를 잡습니다.", + "firstPreChorusBodyBand": "밴드가 {at} {section}에서 리프트를 잡습니다.", + "firstPreChorusArmed": "{at}에서 {role} 프리코러스를 익히세요. 코러스로 들어가세요.", + "firstPreChorusArmedBand": "{at}에서 프리코러스를 익히세요. 코러스로 들어가세요.", + "firstPreChorusUnavailable": "아직 프리코러스가 없습니다. 첫 리프트가 표시될 때까지 오늘 지도에 머무르세요.", + "firstPreChorusNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 프리코러스를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..9c6f5747b 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Pre-Chorus Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `pre-chorus` lift, and the time. Do not invent a pre-chorus from `intro`, `verse`, `chorus`, `bridge`, `outro`, `tag`, `pickup`, `stop`, `handoff`, or the first unlabeled section. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearPreChorus` exists and delegates the exact pre-chorus second to that callback. Keep the unavailable state guidance-only. Distinct from first-verse #947, first-intro #943, first-chorus #939, first-bridge #946, and first-stop #934. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-prechorus-navigation.md b/docs/doctoring/reduced-motion-first-prechorus-navigation.md new file mode 100644 index 000000000..45cc4c03d --- /dev/null +++ b/docs/doctoring/reduced-motion-first-prechorus-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-pre-chorus navigation + +Workspace map navigation for tonight's first pre-chorus follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstPreChorusCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Pre-chorus resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, and role identifiers are used as copy values, local completion-state identity, and effect dependencies; they are not DOM-ID authority. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. From 711bfd4949c61e9ad1e37be8b5767b9dd84039b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:43:08 -0700 Subject: [PATCH 2/9] test(workspace): reject unbounded pre-chorus display times --- .../workspace/firstPreChorus.time-bound.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts diff --git a/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts b/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts new file mode 100644 index 000000000..6452c79e5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS } from "@bandscope/shared-types"; +import { formatPreChorusTime } from "./firstPreChorus"; + +describe("formatPreChorusTime", () => { + it("fails closed when formatter input exceeds the shared timing bound", () => { + expect(formatPreChorusTime(MAX_SECTION_TIME_SECONDS + 1)).toBe("0:00"); + expect(formatPreChorusTime(Number.MAX_VALUE)).toBe("0:00"); + }); +}); From 71594314519eecbfd68fc9cef9903569abff046b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:44:07 -0700 Subject: [PATCH 3/9] fix(workspace): bound pre-chorus display times --- apps/desktop/src/features/workspace/firstPreChorus.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstPreChorus.ts b/apps/desktop/src/features/workspace/firstPreChorus.ts index 27a19bb71..9ca8ad9a8 100644 --- a/apps/desktop/src/features/workspace/firstPreChorus.ts +++ b/apps/desktop/src/features/workspace/firstPreChorus.ts @@ -14,9 +14,14 @@ export type FirstPreChorus = { atSeconds: number; }; -/** Format a non-negative pre-chorus time as m:ss for rehearsal copy. */ +/** Format a bounded, non-negative pre-chorus time as m:ss for rehearsal copy. */ export function formatPreChorusTime(totalSeconds: number): string { - const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const safeSeconds = + Number.isFinite(totalSeconds) && + totalSeconds >= 0 && + totalSeconds <= MAX_SECTION_TIME_SECONDS + ? totalSeconds + : 0; const minutes = Math.floor(safeSeconds / 60); const seconds = Math.floor(safeSeconds % 60) .toString() From 5609a7ac1bb3aa1ef0a09e8d92c1f5efc77e7785 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:44:34 -0700 Subject: [PATCH 4/9] docs(i18n): describe translator contract --- apps/desktop/src/i18n/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index caed9f580..dbac2891a 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -19,7 +19,7 @@ const sectionFormLabels: Readonly< ko: { "pre-chorus": "프리코러스" } }; -/** Documented. */ +/** Create a translation function that resolves keys for the selected locale. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; From 78d916fd7ac4cdc3b481926117d9510bb1385535 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:46:37 -0700 Subject: [PATCH 5/9] test(player): require locale-consistent Korean summary --- .../player/index.localization.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/desktop/src/features/player/index.localization.test.tsx diff --git a/apps/desktop/src/features/player/index.localization.test.tsx b/apps/desktop/src/features/player/index.localization.test.tsx new file mode 100644 index 000000000..8d65bac3f --- /dev/null +++ b/apps/desktop/src/features/player/index.localization.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function songWithPreChorus() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-localization"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + song.sections = [preChorus]; + return song; +} + +describe("PlayerFeature localization", () => { + it("keeps the Korean player summary and playback notice locale-consistent", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("1개 섹션")).toBeTruthy(); + expect(screen.queryByText("1 section")).toBeNull(); + expect(screen.getByText("프리코러스")).toBeTruthy(); + expect(screen.queryByText("pre-chorus")).toBeNull(); + expect( + screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.") + ).toBeTruthy(); + expect( + screen.queryByText("Audio playback requires the desktop app with a local audio source.") + ).toBeNull(); + }); +}); From 40130a185e29ef224ffc94e434a4090009a3f3b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:47:25 -0700 Subject: [PATCH 6/9] feat(i18n): add localized player summary copy --- apps/desktop/src/locales/en/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 3841a1c86..6d8bc378d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "Local analysis", "metricConfidenceSectionSingular": "section", "metricConfidenceSectionPlural": "sections", + "metricConfidenceSectionCountSingular": "{count} section", + "metricConfidenceSectionCountPlural": "{count} sections", "metricPriorityFallback": "Pick track", "metricPriorityPendingDetail": "Choose or open audio", "loadProjectFailedPrefix": "Failed to load project", @@ -149,6 +151,7 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "playerAudioPlaybackRequiresDesktop": "Audio playback requires the desktop app with a local audio source.", "firstPreChorusLabel": "Tonight's first pre-chorus", "firstPreChorusAction": "Hear {role} pre-chorus at {at}", "firstPreChorusActionBand": "Hear the first pre-chorus at {at}", From cb7ce76696e7c83b97cbd3abd333aa248f639e0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:48:03 -0700 Subject: [PATCH 7/9] feat(i18n): add Korean player summary copy --- apps/desktop/src/locales/ko/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index d48e7589d..21f477859 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "로컬 분석", "metricConfidenceSectionSingular": "구간", "metricConfidenceSectionPlural": "구간", + "metricConfidenceSectionCountSingular": "{count}개 섹션", + "metricConfidenceSectionCountPlural": "{count}개 섹션", "metricPriorityFallback": "트랙 선택", "metricPriorityPendingDetail": "오디오를 선택하거나 여세요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", @@ -149,6 +151,7 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "playerAudioPlaybackRequiresDesktop": "오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.", "firstPreChorusLabel": "오늘 첫 프리코러스", "firstPreChorusAction": "{at}에 {role} 프리코러스 듣기", "firstPreChorusActionBand": "{at} 첫 프리코러스 듣기", From d1975e182f529cad3acba1c6c6d7d0a74ac4e47a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 00:49:42 -0700 Subject: [PATCH 8/9] fix(player): localize pre-chorus summary surface --- apps/desktop/src/features/player/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index ee8bab502..8176bfd96 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -5,7 +5,7 @@ import { type SectionFormLabel } from "@bandscope/shared-types"; import { FirstPreChorusCallout } from "../workspace/FirstPreChorusCallout"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; type PlayerFeatureProps = { title: string; @@ -47,7 +47,8 @@ function playerSummarySections(song: RehearsalSong): RehearsalSection[] { /** Player surface that names tonight's first labeled pre-chorus and delegates playback to the owning player. */ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { - const t = createTranslator(detectPreferredLocale()); + const locale = detectPreferredLocale(); + const t = createTranslator(locale); if (!song) { return ( @@ -60,6 +61,11 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP const sections = playerSummarySections(song); const songTitle = typeof song.title === "string" ? song.title : ""; + const sectionCountLabel = t( + sections.length === 1 + ? "metricConfidenceSectionCountSingular" + : "metricConfidenceSectionCountPlural" + ).replace("{count}", String(sections.length)); return (
@@ -77,7 +83,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
{songTitle} - {sections.length} {sections.length === 1 ? "section" : "sections"} + {sectionCountLabel}
@@ -93,12 +99,12 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP textTransform: "capitalize" }} > - {section.label} + {translateSectionFormLabel(locale, section.label)} ))}
- Audio playback requires the desktop app with a local audio source. + {t("playerAudioPlaybackRequiresDesktop")}
From 4058e5094bff94f9ed2df0f635313ff244f225a6 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 16:04:18 +0900 Subject: [PATCH 9/9] fix(changelog): preserve heading spacing --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2327bd01d..a0ffad0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed + - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. + ### Fixed - Reset first-pre-chorus action completion when the runtime song object changes, so a replacement song cannot inherit success-shaped Open/Hear guidance merely because both songs have malformed or missing ids and the same pre-chorus metadata. @@ -74,4 +76,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).