From 62b1bbe9025b832c7d8c0ef33e9b1ef689d4fb01 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 19 Aug 2026 22:12:11 +0000 Subject: [PATCH 1/9] feat(workspace): guide tonight's first verse on map and player Name the earliest labeled verse so the room can open the story line on the map or hear it from the player. Fail closed on intro, pre-chorus, chorus, bridge, outro, tag, pickup, stop, and handoff labels, and keep Hear behind a real seek callback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 97 +++++++++ apps/desktop/src/features/player/index.tsx | 70 ++++++- .../FirstVerseCallout.reduced-motion.test.tsx | 67 ++++++ .../workspace/FirstVerseCallout.test.tsx | 162 +++++++++++++++ .../features/workspace/FirstVerseCallout.tsx | 152 ++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 44 ++++ .../src/features/workspace/Workspace.tsx | 3 + .../src/features/workspace/firstVerse.test.ts | 190 +++++++++++++++++ .../src/features/workspace/firstVerse.ts | 192 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 18 +- apps/desktop/src/i18n/index.ts | 14 ++ apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-verse-navigation.md | 14 ++ 19 files changed, 1041 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVerseCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVerseCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstVerse.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVerse.ts create mode 100644 docs/doctoring/reduced-motion-first-verse-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..5317f8cd7 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 verse with the holding part when an active role is corroborated, the labeled story line, 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..89f6a45e3 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 verse must name the holding part when corroborated, the labeled story line, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..e2ba9f5be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first labeled verse on the workspace and player so the room can learn the first line; 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..e0a3529df 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 verse so the room can learn the first line; 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..6fdc99c48 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,97 @@ +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 songWithVerse() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 30 }; + verse.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + verse.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse]; + 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 verse from this player.") + ).toBeTruthy(); + }); + + it("keeps the verse hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal verse at 0:10" })).toBeNull(); + expect(screen.getByText("Lead Vocal carries the verse at 0:10.")).toBeTruthy(); + }); + + it("delegates the verse hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal verse at 0:10" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(10); + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithVerse(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No verse yet. Stay on tonight's map until the first verse is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithVerse(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No verse yet. Stay on tonight's map until the first verse is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithVerse(); + song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithVerse(); + (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..d876bf500 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 { FirstVerseCallout } from "../workspace/FirstVerseCallout"; +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 verse 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("firstVerseNeedsSong")}

); } + 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/FirstVerseCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..5b80c4509 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.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 { FirstVerseCallout } from "./FirstVerseCallout"; + +function songWithVerse() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 30 }; + verse.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + verse.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse]; + return song; +} + +describe("FirstVerseCallout 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 verse at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx new file mode 100644 index 000000000..d768ecf5c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstVerseCallout } from "./FirstVerseCallout"; + +function songWithVerse() { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 30 }; + verse.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + verse.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse]; + 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("FirstVerseCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the first verse 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 verse at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).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 verse at 0:10" })); + + expect(screen.getByText("Lead Vocal carries the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearVerse = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal verse at 0:10" })); + expect(onHearVerse).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 = songWithVerse(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal verse at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first verse changes or returns later", () => { + const initialSong = songWithVerse(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal verse at 0:10" })); + expect(screen.getByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).toBeTruthy(); + + const nextSong = songWithVerse(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 48, end: 64 }; + rerender(); + expect(screen.getByText("Lead Vocal carries the verse at 0:48.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable verse guidance-only", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.label = "chorus"; + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No verse yet. Stay on tonight's map until the first verse is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide story line when no part holds the verse", () => { + const song = songWithVerse(); + song.sections[0]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first verse at 0:10" })).toBeTruthy(); + expect(screen.getByText("The band carries the verse at 0:10.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearVerse = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal verse at 0:10" })); + expect(onHearVerse).toHaveBeenCalledWith(10); + }); + + 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 verse at 0:10.")).toBeTruthy(); + }); + + it("localizes the verse form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithVerse(); + song.sections[0]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("리드 보컬이 0:10 벌스에서 첫 소절을 잡습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx new file mode 100644 index 000000000..30c121568 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx @@ -0,0 +1,152 @@ +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 { formatVerseTime, resolveFirstVerse } from "./firstVerse"; + +/** Props for the first-verse rehearsal callout. */ +export interface FirstVerseCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearVerse?: (atSeconds: number) => void; +} + +type VerseCopyValues = Readonly>; + +type HeardVerse = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate verse placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatVerseCopy(template: string, values: VerseCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof VerseCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredVerseScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled verse and offer only an action that the current surface can execute. */ +export function FirstVerseCallout({ + song, + actionMode = "workspace-scroll", + onHearVerse +}: FirstVerseCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const verse = resolveFirstVerse(song); + const verseSectionIndex = + verse && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(verse.section) + : -1; + const [heardVerse, setHeardVerse] = useState(null); + + useEffect(() => { + setHeardVerse(null); + }, [songId, verseSectionIndex, verse?.section.id, verse?.holdingRole?.id, verse?.atSeconds]); + + if (!verse) { + return ( + + ); + } + + const heard = + heardVerse?.songId === songId && + heardVerse.sectionId === verse.section.id && + heardVerse.sectionIndex === verseSectionIndex && + heardVerse.holdingRoleId === (verse.holdingRole?.id ?? null) && + heardVerse.atSeconds === verse.atSeconds; + const at = formatVerseTime(verse.atSeconds); + const copyValues: VerseCopyValues = { + role: verse.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, verse.section.label), + at + }; + const hasRole = verse.holdingRole !== null; + const actionLabel = formatVerseCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstVerseAction" + : "firstVerseActionBand" + : hasRole + ? "firstVerseOpenAction" + : "firstVerseOpenActionBand" + ), + copyValues + ); + const body = formatVerseCopy(t(hasRole ? "firstVerseBody" : "firstVerseBodyBand"), copyValues); + const armed = formatVerseCopy(t(hasRole ? "firstVerseArmed" : "firstVerseArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearVerse === "function"; + /** Record completion only after the owning surface has executed the selected verse action. */ + const markVerseActionComplete = () => { + setHeardVerse({ + songId, + sectionId: verse.section.id, + sectionIndex: verseSectionIndex, + holdingRoleId: verse.holdingRole?.id ?? null, + atSeconds: verse.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..173e68d8f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,48 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first verse as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + verse.id = "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 30 }; + verse.roles = [ + { + ...seed.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + verse.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse]; + + 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 verse at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..3bbc7e5e2 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 { FirstVerseCallout } from "./FirstVerseCallout"; 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/firstVerse.test.ts b/apps/desktop/src/features/workspace/firstVerse.test.ts new file mode 100644 index 000000000..f61e28e61 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVerse.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatVerseTime, resolveFirstVerse } from "./firstVerse"; + +function withVerseSection( + 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 verse = structuredClone(seed); + verse.id = overrides.id ?? "verse-1"; + verse.label = "verse"; + verse.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "lead-vocal"; + verse.roles = [ + { + ...seed.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + verse.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse]; + return song; +} + +describe("resolveFirstVerse", () => { + it("picks the labeled verse already on the demo song and the part that carries it", () => { + const first = resolveFirstVerse(createDemoRehearsalSong()); + expect(first?.section.id).toBe("verse-1"); + expect(first?.holdingRole?.id).toBe("bass-guitar"); + expect(first?.atSeconds).toBe(10); + expect(formatVerseTime(first?.atSeconds ?? -1)).toBe("0:10"); + expect(formatVerseTime(Number.NaN)).toBe("0:00"); + expect(formatVerseTime(-4)).toBe("0:00"); + }); + + it("does not invent a verse from an intro, pre-chorus, 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 preChorus = structuredClone(seed); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + 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, preChorus, chorus, handoff, bridge, outro, tag]; + + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("picks the earliest labeled verse and the part that carries the story line", () => { + const song = withVerseSection({ start: 10, end: 30 }); + const first = resolveFirstVerse(song); + + expect(first?.section.id).toBe("verse-1"); + expect(first?.holdingRole?.id).toBe("lead-vocal"); + expect(first?.atSeconds).toBe(10); + expect(formatVerseTime(first?.atSeconds ?? -1)).toBe("0:10"); + }); + + it("prefers the earlier of two labeled verses", () => { + const song = withVerseSection({ id: "verse-late", start: 48, end: 64 }); + const seed = song.sections[0]!; + const earlier = structuredClone(seed); + earlier.id = "verse-early"; + earlier.timeRange = { start: 10, end: 30 }; + 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 = resolveFirstVerse(song); + expect(first?.section.id).toBe("verse-early"); + expect(first?.holdingRole?.id).toBe("bass-guitar"); + expect(first?.atSeconds).toBe(10); + }); + + it("keeps a band-wide story line when no active ranked role holds it", () => { + const song = withVerseSection({ isActive: false }); + const first = resolveFirstVerse(song); + expect(first?.section.id).toBe("verse-1"); + expect(first?.holdingRole).toBeNull(); + expect(first?.atSeconds).toBe(10); + }); + + it("skips a verse whose rehearsal window is unbounded", () => { + const song = withVerseSection({ start: Number.NaN, end: 30 }); + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("skips a verse whose end precedes its start", () => { + const song = withVerseSection({ start: 30, end: 10 }); + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("skips a zero-length verse window", () => { + const song = withVerseSection({ start: 10, end: 10 }); + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("skips a verse whose endpoint overflows the shared timing bound", () => { + const song = withVerseSection({ start: MAX_SECTION_TIME_SECONDS, end: MAX_SECTION_TIME_SECONDS + 1 }); + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstVerse(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withVerseSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstVerse(song)).toBeNull(); + }); + + it("keeps the story line band-wide when role identities are duplicated", () => { + const song = withVerseSection(); + 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 = resolveFirstVerse(song); + expect(first?.section.id).toBe("verse-1"); + expect(first?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVerse.ts b/apps/desktop/src/features/workspace/firstVerse.ts new file mode 100644 index 000000000..07ab9804d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVerse.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 verse: the earliest story line and the part that carries it. */ +export type FirstVerse = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative verse time as m:ss for rehearsal copy. */ +export function formatVerseTime(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 verse, or null when no safe story line remains. */ +export function resolveFirstVerse(song: RehearsalSong): FirstVerse | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const verseSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "verse" && + 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 = verseSections[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..439887f0a 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 verse label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + }); + + 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..dab728507 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: { verse: "verse" }, + ko: { verse: "벌스" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,12 @@ 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]; + return Object.prototype.hasOwnProperty.call(labels, label) ? (labels[label] ?? label) : label; +} + /** 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..0f9be89d6 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", + "firstVerseLabel": "Tonight's first verse", + "firstVerseAction": "Hear {role} verse at {at}", + "firstVerseActionBand": "Hear the first verse at {at}", + "firstVerseOpenAction": "Open {role} verse at {at}", + "firstVerseOpenActionBand": "Open the first verse at {at}", + "firstVerseBody": "{role} carries the {section} at {at}.", + "firstVerseBodyBand": "The band carries the {section} at {at}.", + "firstVerseArmed": "Learn {role}'s verse at {at}. Play the first line.", + "firstVerseArmedBand": "Learn the verse at {at}. Play the first line.", + "firstVerseUnavailable": "No verse yet. Stay on tonight's map until the first verse is labeled.", + "firstVerseNeedsSong": "Analyze tonight's song first, then hear the first verse from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..4e7083145 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": "진척도 증가", + "firstVerseLabel": "오늘 첫 벌스", + "firstVerseAction": "{at}에 {role} 벌스 듣기", + "firstVerseActionBand": "{at} 첫 벌스 듣기", + "firstVerseOpenAction": "{at} {role} 벌스 위치 열기", + "firstVerseOpenActionBand": "{at} 첫 벌스 위치 열기", + "firstVerseBody": "{role}이 {at} {section}에서 첫 소절을 잡습니다.", + "firstVerseBodyBand": "밴드가 {at} {section}에서 첫 소절을 잡습니다.", + "firstVerseArmed": "{at}에서 {role} 벌스를 익히세요. 첫 소절을 연주하세요.", + "firstVerseArmedBand": "{at}에서 벌스를 익히세요. 첫 소절을 연주하세요.", + "firstVerseUnavailable": "아직 벌스가 없습니다. 첫 벌스가 표시될 때까지 오늘 지도에 머무르세요.", + "firstVerseNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 벌스를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..70014c1cb 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 Verse Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstVerseCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `verse` story line, and the time. Do not invent a verse from `intro`, `pre-chorus`, `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 `onHearVerse` exists and delegates the exact verse second to that callback. Keep the unavailable state guidance-only. Distinct from 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-verse-navigation.md b/docs/doctoring/reduced-motion-first-verse-navigation.md new file mode 100644 index 000000000..dfd098bc5 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-verse-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-verse navigation + +Workspace map navigation for tonight's first verse follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstVerseCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Verse 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 6125221ab28147ae45986e14b71ebd20e6ea5f1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:31:19 -0700 Subject: [PATCH 2/9] test(workspace): reject stale verse completion across songs --- .../workspace/FirstVerseCallout.test.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx index d768ecf5c..fd524f18f 100644 --- a/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx @@ -118,6 +118,25 @@ describe("FirstVerseCallout", () => { grid.remove(); }); + it("does not carry completed guidance into a replacement song with an invalid runtime id", () => { + const firstSong = songWithVerse(); + const replacementSong = songWithVerse(); + (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 verse at 0:10" })); + expect(screen.getByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal carries the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Learn Lead Vocal's verse at 0:10. Play the first line./)).toBeNull(); + + grid.remove(); + }); + it("keeps an unavailable verse guidance-only", () => { const song = createDemoRehearsalSong(); song.sections[0]!.label = "chorus"; @@ -159,4 +178,4 @@ describe("FirstVerseCallout", () => { expect(screen.getByText("리드 보컬이 0:10 벌스에서 첫 소절을 잡습니다.")).toBeTruthy(); expect(screen.queryByText(/verse에서/)).toBeNull(); }); -}); +}); \ No newline at end of file From a1f2c73834cd1268f598d2dffa0a29e55ed9d8c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:32:17 -0700 Subject: [PATCH 3/9] fix(workspace): bind verse completion to current song --- .../src/features/workspace/FirstVerseCallout.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx index 30c121568..88b53d9de 100644 --- a/apps/desktop/src/features/workspace/FirstVerseCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx @@ -18,7 +18,7 @@ export interface FirstVerseCalloutProps { type VerseCopyValues = Readonly>; type HeardVerse = Readonly<{ - songId: string; + song: RehearsalSong; sectionId: string; sectionIndex: number; holdingRoleId: string | null; @@ -50,7 +50,6 @@ export function FirstVerseCallout({ const locale = detectPreferredLocale(); const t = createTranslator(locale); const runtimeSong = song as unknown as Partial | null; - const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; const verse = resolveFirstVerse(song); const verseSectionIndex = verse && Array.isArray(runtimeSong?.sections) @@ -60,7 +59,7 @@ export function FirstVerseCallout({ useEffect(() => { setHeardVerse(null); - }, [songId, verseSectionIndex, verse?.section.id, verse?.holdingRole?.id, verse?.atSeconds]); + }, [song, verseSectionIndex, verse?.section.id, verse?.holdingRole?.id, verse?.atSeconds]); if (!verse) { return ( @@ -76,7 +75,7 @@ export function FirstVerseCallout({ } const heard = - heardVerse?.songId === songId && + heardVerse?.song === song && heardVerse.sectionId === verse.section.id && heardVerse.sectionIndex === verseSectionIndex && heardVerse.holdingRoleId === (verse.holdingRole?.id ?? null) && @@ -106,7 +105,7 @@ export function FirstVerseCallout({ /** Record completion only after the owning surface has executed the selected verse action. */ const markVerseActionComplete = () => { setHeardVerse({ - songId, + song, sectionId: verse.section.id, sectionIndex: verseSectionIndex, holdingRoleId: verse.holdingRole?.id ?? null, @@ -149,4 +148,4 @@ export function FirstVerseCallout({ ) : null} ); -} +} \ No newline at end of file From d77924814b33c2977bd2ad331421b9bbad9b149f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:35:02 -0700 Subject: [PATCH 4/9] style(workspace): restore verse callout newline --- apps/desktop/src/features/workspace/FirstVerseCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx index 88b53d9de..1c8fb21f4 100644 --- a/apps/desktop/src/features/workspace/FirstVerseCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.tsx @@ -148,4 +148,4 @@ export function FirstVerseCallout({ ) : null} ); -} \ No newline at end of file +} From 6f71d87091a3028de120051bf5876773fd4d12b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:42:17 -0700 Subject: [PATCH 5/9] docs(workspace): record verse completion reset --- CHANGELOG.md | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ba9f5be..44c8d9f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Reset first-verse 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 verse metadata. + ## [0.1.3] - 2026-04-29 ### Fixed @@ -46,24 +50,6 @@ ### Added -- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) -- Issue #40: Enforced 100% Python docstring and test coverage -- Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- Initial public release. +- Implemented secure local audio intake and project bootstrap. +- Added foundational analysis-engine, desktop workspace, shared contracts, and release workflows. From 56e5b81150fba328028c58b98052751a66c4d7ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 19:43:04 -0700 Subject: [PATCH 6/9] fix(changelog): preserve release history for verse repair --- CHANGELOG.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c8d9f02..35d322dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,24 @@ ### Added -- Initial public release. -- Implemented secure local audio intake and project bootstrap. -- Added foundational analysis-engine, desktop workspace, shared contracts, and release workflows. +- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #40: Enforced 100% Python docstring and test coverage +- Issue #32: Implemented local analysis orchestration and secure IPC boundaries +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 315e563ffe6d6ad83c48117e308477f8ca280cbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:22:08 -0700 Subject: [PATCH 7/9] test(workspace): lock particle-safe Korean verse copy --- .../FirstVerseCallout.particle.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstVerseCallout.particle.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.particle.test.tsx new file mode 100644 index 000000000..f50ca490e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.particle.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstVerseCallout } from "./FirstVerseCallout"; + +describe("FirstVerseCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const verse = structuredClone(seed); + verse.id = "verse-particle"; + verse.label = "verse"; + verse.timeRange = { start: 10, end: 30 }; + verse.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; + verse.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [verse]; + + render(); + + expect(screen.getByText("0:10 벌스에서 피아노 파트가 첫 소절을 잡습니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:10 벌스에서 첫 소절을 잡습니다.")).toBeNull(); + }); +}); From 1e19369d8ed49c7281285d7d2a6b5414d07d58f1 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 24 Aug 2026 16:04:16 +0900 Subject: [PATCH 8/9] fix(ci): use particle-safe Korean verse body copy for vowel-ending role names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ko firstVerseBody template attached the subject particle directly to the dynamic role name ("{role}이 ..."), which produces ungrammatical copy for vowel-ending names such as 피아노 (피아노이). Restructure the template to "{at} {section}에서 {role} 파트가 첫 소절을 잡습니다." so particles always attach to the fixed noun 파트, matching the particle-safe callout convention used across the workspace-first series, and update the main callout test expectation accordingly. --- apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx | 2 +- apps/desktop/src/locales/ko/common.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx index fd524f18f..fdf8375b8 100644 --- a/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstVerseCallout.test.tsx @@ -175,7 +175,7 @@ describe("FirstVerseCallout", () => { render(); - expect(screen.getByText("리드 보컬이 0:10 벌스에서 첫 소절을 잡습니다.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 리드 보컬 파트가 첫 소절을 잡습니다.")).toBeTruthy(); expect(screen.queryByText(/verse에서/)).toBeNull(); }); }); \ No newline at end of file diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 4e7083145..21a90eaae 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,7 +154,7 @@ "firstVerseActionBand": "{at} 첫 벌스 듣기", "firstVerseOpenAction": "{at} {role} 벌스 위치 열기", "firstVerseOpenActionBand": "{at} 첫 벌스 위치 열기", - "firstVerseBody": "{role}이 {at} {section}에서 첫 소절을 잡습니다.", + "firstVerseBody": "{at} {section}에서 {role} 파트가 첫 소절을 잡습니다.", "firstVerseBodyBand": "밴드가 {at} {section}에서 첫 소절을 잡습니다.", "firstVerseArmed": "{at}에서 {role} 벌스를 익히세요. 첫 소절을 연주하세요.", "firstVerseArmedBand": "{at}에서 벌스를 익히세요. 첫 소절을 연주하세요.", From 38ed1c8f4dc5f020db43536596aa401c454db55c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 16:04:11 +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 1a95bbbbc..81568471a 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-verse 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 verse 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`).