diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..c2a10a78b 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 corroborated part-to-part handoff from the source section's active giving role and owned `handoff_to` / receiving `handoff_from` evidence, then resolve the receiving role and cue against the immediately following destination section where that role becomes active. Show the destination label and start time, and make Open navigate to that rendered destination. Do not invent a pass from one-section same-state graphs, groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, confirmed overrides, harmonic explanations, confidence notes, transposition plans, or a labeled `handoff` form. Distinct from labeled-form handoff (#937) and Part Handoff Map visualization (#850). - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..704ae5774 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-24 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first corroborated part-to-part handoff must treat `partGraph.handoff_to` as a transition from the current source section into the immediately following destination section: name the active giving role from the source, corroborate the receiver with source `handoff_from`, require the giver to deactivate and receiver to activate in the destination, and show the destination label/start time. Open moves to that rendered destination section. Do not invent a pass from a one-section same-state graph, groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, confirmed overrides, harmonic explanations, confidence notes, transposition plans, or a labeled `handoff` form. Distinct from labeled-form handoff (#937) and Part Handoff Map visualization (#850). - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source @@ -80,7 +81,7 @@ Last updated: 2026-03-11 - Core rehearsal artifacts should include: - likely harmony by section and by role - - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs + - section roadmap with entries, dropouts, pickups, stops, tags, labeled form handoffs, and corroborated part-to-part handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check - simplification, transposition, capo, tuning, or setup cues where applicable diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..1f3566e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first corroborated part-to-part handoff in the mounted rehearsal workspace so the room can lock the owned pass before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. Distinct from labeled-form handoff (#937) and Part Handoff Map visualization (#850). - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..5a27eba1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first corroborated part-to-part handoff and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent that pass from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, confirmed overrides, harmonic explanations, confidence notes, transposition plans, or a labeled `handoff` form without owned bidirectional `partGraph` edges. Distinct from labeled-form handoff (#937) and Part Handoff Map visualization (#850). `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/workspace/FirstPartHandoffCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.particle.test.tsx new file mode 100644 index 000000000..0da8f61a6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.particle.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("FirstPartHandoffCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the destination handoff action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createPartHandoffTransitionSong(); + const source = song.sections[0]!; + const destination = song.sections[1]!; + source.roles[0] = { ...source.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }; + source.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: ["vocal"], handoff_from: [] }, + { role_id: "vocal", is_active: false, handoff_to: [], handoff_from: ["piano"] } + ]; + destination.roles[0] = { ...destination.roles[0]!, id: "vocal", name: "보컬", rehearsalPriority: "medium" }; + destination.partGraph = [ + { role_id: "piano", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + const grid = document.createElement("div"); + grid.id = "workspace-song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const sourceTarget = document.createElement("div"); + sourceTarget.dataset.sectionIndex = "0"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + Object.defineProperty(target, "scrollIntoView", { configurable: true, value: vi.fn() }); + grid.append(sourceTarget, target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:10 코러스에서 피아노 파트가 보컬 파트로 넘깁니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 핸드오프 위치 열기" })); + + expect(screen.getByText("0:10에서 피아노 파트에서 보컬 파트로 넘긴 다음 합주를 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPartHandoffCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..d23980548 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.reduced-motion.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("FirstPartHandoffCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately to the destination 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.id = "workspace-song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const source = document.createElement("div"); + source.dataset.sectionIndex = "0"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.append(source, target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPartHandoffCallout.role-scope.test.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.role-scope.test.tsx new file mode 100644 index 000000000..ef11be8d3 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.role-scope.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("FirstPartHandoffCallout selected role scope", () => { + it("keeps the handoff when the selected role receives the pass", () => { + render( + + ); + + expect(screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })).toBeTruthy(); + }); + + it("keeps an unrelated selected role guidance-only", () => { + render( + + ); + + expect( + screen.getByText( + "Nothing still has a part handoff. Stay on tonight's map until a part owns a rehearsal-facing pass." + ) + ).toBeTruthy(); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPartHandoffCallout.test.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.test.tsx new file mode 100644 index 000000000..79b1205d1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.test.tsx @@ -0,0 +1,211 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +function songWithPartHandoff() { + return createPartHandoffTransitionSong(); +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.id = "workspace-song-structure-grid"; + const source = document.createElement("div"); + source.dataset.sectionIndex = "0"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.append(source, target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstPartHandoffCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + expect( + screen.getByText( + "Nothing still has a part handoff. Stay on tonight's map until a part owns a rehearsal-facing pass." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithPartHandoff(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same handoff signature", () => { + const firstSong = songWithPartHandoff(); + const nextSong = songWithPartHandoff(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + expect(screen.getByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./)).toBeTruthy(); + + rerender(); + expect(screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./)).toBeNull(); + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithPartHandoff(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + rerender(); + + expect(screen.getByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./)).toBeTruthy(); + expect(screen.queryByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.")).toBeNull(); + grid.remove(); + }); + + it("uses the destination role name rather than a stale source-role name", () => { + const song = songWithPartHandoff(); + const source = song.sections[0]!; + const destination = song.sections[1]!; + source.partGraph[0]!.handoff_to = ["keys-right"]; + source.partGraph[1]!.role_id = "keys-right"; + source.partGraph[1]!.handoff_from = ["bass-guitar"]; + destination.roles[0] = { ...destination.roles[0]!, id: "keys-right", name: "Keyboard 1 Right Hand" }; + destination.partGraph[1]!.role_id = "keys-right"; + + render(); + + expect(screen.getByText("Bass Guitar still hands off to Keyboard 1 Right Hand in the chorus at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Lead Vocal/)).toBeNull(); + }); + + it("opens the destination map section and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./)).toBeTruthy(); + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + grid.remove(); + }); + + it("does not claim map navigation completed when the destination target is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + + expect(screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./)).toBeNull(); + }); + + it("navigates by renderer-owned destination position instead of untrusted analysis ids", () => { + const song = songWithPartHandoff(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "1"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { configurable: true, value: decoyScrollIntoView }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the destination timing changes", () => { + const initialSong = songWithPartHandoff(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })); + + const nextSong = songWithPartHandoff(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:20.")).toBeTruthy(); + grid.remove(); + }); + + it("keeps an unavailable part handoff guidance-only", () => { + const song = songWithPartHandoff(); + for (const node of song.sections[0]!.partGraph) { + node.handoff_to = []; + node.handoff_from = []; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByRole("complementary", { name: "Tonight's first part handoff" })).toBeTruthy(); + }); + + it("localizes the destination form label in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithPartHandoff(); + song.sections[0]!.roles[0]!.name = "베이스"; + song.sections[1]!.roles[0]!.name = "보컬"; + + render(); + expect(screen.getByText("0:10 코러스에서 베이스 파트가 보컬 파트로 넘깁니다.")).toBeTruthy(); + expect(screen.queryByText(/chorus에서/)).toBeNull(); + }); + + it("renders owned role names as text nodes instead of template syntax", () => { + const song = songWithPartHandoff(); + song.sections[0]!.roles[0]!.name = "Check {from} at {at}"; + render(); + expect(screen.getByText("Check {from} at {at} still hands off to Lead Vocal in the chorus at 0:10.")).toBeTruthy(); + expect(screen.queryByText("Check Bass Guitar at 0:10 still hands off to Lead Vocal in the chorus at 0:10.")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPartHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.tsx new file mode 100644 index 000000000..1e8b6d97a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.tsx @@ -0,0 +1,183 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatPartHandoffTime, resolveFirstPartHandoff } from "./firstPartHandoff"; + +/** Props for the first part-handoff rehearsal callout. */ +export interface FirstPartHandoffCalloutProps { + song: RehearsalSong; + activeRole?: string | null; +} + +type PartHandoffCopyValues = Readonly>; + +type OpenedPartHandoff = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + givingRoleId: string; + receivingRoleId: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stablePartHandoffSongIdentity(song: RehearsalSong): unknown { + if (song === null || typeof song !== "object" || Array.isArray(song)) { + return song; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(song, "id"); + } catch { + return song; + } + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ? descriptor.value + : song; +} + +/** Interpolate part-handoff placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatPartHandoffCopy(template: string, values: PartHandoffCopyValues): string { + return template.replace(/\{(from|to|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof PartHandoffCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredPartHandoffScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolvePartHandoffRenderer(origin: HTMLElement): HTMLElement | null { + const selector = "#workspace-song-structure-grid"; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first part handoff and open the matching rendered map section. */ +export function FirstPartHandoffCallout({ + song, + activeRole = null +}: FirstPartHandoffCalloutProps) { + const calloutId = useId(); + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stablePartHandoffSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const named = useMemo(() => resolveFirstPartHandoff(song, activeRole), [activeRole, song]); + const namedSectionIndex = + named && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(named.section) + : -1; + const [openedPartHandoff, setOpenedPartHandoff] = useState(null); + + useEffect(() => { + setOpenedPartHandoff(null); + }, [ + songIdentity, + namedSectionIndex, + named?.section.id, + named?.givingRole.id, + named?.receivingRole.id, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedPartHandoff !== null && + openedPartHandoff.songIdentity === songIdentity && + openedPartHandoff.sectionId === named.section.id && + openedPartHandoff.sectionIndex === namedSectionIndex && + openedPartHandoff.givingRoleId === named.givingRole.id && + openedPartHandoff.receivingRoleId === named.receivingRole.id && + openedPartHandoff.atSeconds === named.atSeconds; + const at = formatPartHandoffTime(named.atSeconds); + const copyValues: PartHandoffCopyValues = { + from: named.givingName, + to: named.receivingName, + section: translateSectionFormLabel(locale, named.section.label), + at + }; + const actionLabel = formatPartHandoffCopy(t("firstPartHandoffOpenAction"), copyValues); + const body = formatPartHandoffCopy(t("firstPartHandoffBody"), copyValues); + const armed = formatPartHandoffCopy(t("firstPartHandoffArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstPartHandoffCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..44e3559e6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPartHandoffCallout.workspace-scope.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("FirstPartHandoffCallout workspace scope", () => { + it("opens the destination renderer owned by the current workspace", () => { + const firstSong = createPartHandoffTransitionSong(); + const secondSong = createPartHandoffTransitionSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="1"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Bass Guitar handoff at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.first-part-handoff-role-scope.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-part-handoff-role-scope.test.tsx new file mode 100644 index 000000000..3ce213d55 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.first-part-handoff-role-scope.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("Workspace first part handoff selected-role integration", () => { + it("keeps unrelated roles guidance-only while preserving giver and receiver handoffs", () => { + const song = createPartHandoffTransitionSong(); + const demoSong = createDemoRehearsalSong(); + const keysRole = demoSong.sections + .flatMap((section) => section.roles) + .find((role) => role.id === "keys-right"); + + if (!keysRole) { + throw new Error("Demo fixture must contain the unrelated keys-right role"); + } + + song.sections[0] = { + ...song.sections[0]!, + roles: [...song.sections[0]!.roles, structuredClone(keysRole)] + }; + + render(); + + expect( + screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.") + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" })); + + expect( + screen.getByText( + "Nothing still has a part handoff. Stay on tonight's map until a part owns a rehearsal-facing pass." + ) + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Open Bass Guitar handoff at 0:10" }) + ).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect( + screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.") + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + expect( + screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.") + ).toBeTruthy(); + }); + + it("does not let a selected role from the previous song hide a new song handoff", () => { + const previousSong = createDemoRehearsalSong(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" })); + + const nextSong = createPartHandoffTransitionSong(); + rerender(); + + expect( + screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.") + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Bass Guitar handoff at 0:10" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..7abaf8e29 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; import { EmptyState, LoadingState } from "./WorkspaceStates"; import { generateMetadataHandoffJson } from "../../lib/export"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; @@ -326,4 +327,32 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first part handoff as destination workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createPartHandoffTransitionSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(1); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getByText("Bass Guitar still hands off to Lead Vocal in the chorus at 0:10.") + ).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar handoff at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that pass from Bass Guitar to Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..5e515e9d1 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, memo, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; +import { FirstPartHandoffCallout } from "./FirstPartHandoffCallout"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; @@ -88,11 +89,16 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R >
- {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -353,6 +359,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
@@ -512,4 +520,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstPartHandoff.inherited-metadata.test.ts new file mode 100644 index 000000000..7f7708394 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.inherited-metadata.test.ts @@ -0,0 +1,105 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPartHandoff } from "./firstPartHandoff"; + +function songWithPartHandoff() { + const song = createDemoRehearsalSong(); + const template = structuredClone(song.sections[0]!); + const bass = { ...template.roles[0]!, id: "bass-guitar", name: "Bass Guitar", rehearsalPriority: "high" as const }; + const vocal = { ...template.roles[2]!, id: "lead-vocal", name: "Lead Vocal", rehearsalPriority: "medium" as const }; + const source = { + ...structuredClone(template), + id: "handoff-source", + timeRange: { start: 0, end: 10 }, + roles: [bass], + partGraph: [ + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: ["bass-guitar"] } + ] + }; + const destination = { + ...structuredClone(template), + id: "handoff-destination", + label: "chorus" as const, + timeRange: { start: 10, end: 30 }, + roles: [vocal], + partGraph: [ + { role_id: "bass-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ] + }; + song.sections = [source, destination]; + return { song, source, destination }; +} + +describe("resolveFirstPartHandoff inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, source, destination } = songWithPartHandoff(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstPartHandoff(inheritedSong)).toBeNull(); + + song.sections = [Object.create(source) as typeof source, destination]; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("rejects inherited destination timing fields", () => { + const { song, destination } = songWithPartHandoff(); + destination.timeRange = Object.create({ start: 10, end: 30 }) as typeof destination.timeRange; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, source } = songWithPartHandoff(); + Object.defineProperty(source.partGraph[0]!, "handoff_to", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile handoff_to getter"); + } + }); + + expect(() => resolveFirstPartHandoff(song)).not.toThrow(); + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("does not treat own accessors as stable destination identity authority", () => { + const { song, destination } = songWithPartHandoff(); + Object.defineProperty(destination, "id", { + configurable: true, + enumerable: true, + get() { + return "handoff-destination"; + } + }); + + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("does not let inherited source handoff edges establish the named pass", () => { + const { song, source } = songWithPartHandoff(); + const inheritedNode = Object.create({ + handoff_to: ["lead-vocal"] + }) as (typeof source.partGraph)[0]; + Object.defineProperties(inheritedNode, { + role_id: { configurable: true, enumerable: true, value: "bass-guitar" }, + is_active: { configurable: true, enumerable: true, value: true }, + handoff_from: { configurable: true, enumerable: true, value: [] } + }); + source.partGraph = [inheritedNode, source.partGraph[1]!]; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the giving part", () => { + const { song, source } = songWithPartHandoff(); + const node = source.partGraph[0]!; + source.partGraph = [Object.create(node) as typeof node, source.partGraph[1]!]; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, source, destination } = songWithPartHandoff(); + const arraySection = Object.assign([], source) as unknown as typeof source; + song.sections = [arraySection, destination]; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.role-scope.test.ts b/apps/desktop/src/features/workspace/firstPartHandoff.role-scope.test.ts new file mode 100644 index 000000000..fbff868ea --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.role-scope.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveFirstPartHandoff } from "./firstPartHandoff"; +import { createPartHandoffTransitionSong } from "./firstPartHandoff.test-fixture"; + +describe("resolveFirstPartHandoff selected role scope", () => { + it("keeps a transition when the selected role gives or receives the pass", () => { + const song = createPartHandoffTransitionSong(); + + expect(resolveFirstPartHandoff(song, "bass-guitar")?.receivingRole.id).toBe("lead-vocal"); + expect(resolveFirstPartHandoff(song, "lead-vocal")?.givingRole.id).toBe("bass-guitar"); + }); + + it("fails closed when the selected role is not on the pass", () => { + expect(resolveFirstPartHandoff(createPartHandoffTransitionSong(), "keys-right")).toBeNull(); + expect(resolveFirstPartHandoff(createPartHandoffTransitionSong(), "missing-role")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.test-fixture.ts b/apps/desktop/src/features/workspace/firstPartHandoff.test-fixture.ts new file mode 100644 index 000000000..f91dfda12 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.test-fixture.ts @@ -0,0 +1,37 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; + +/** Build a two-section fixture matching analysis-engine deactivate/activate handoff topology. */ +export function createPartHandoffTransitionSong(): RehearsalSong { + const song = createDemoRehearsalSong(); + const template = song.sections[0]!; + const bass = template.roles.find((role) => role.id === "bass-guitar"); + const vocal = template.roles.find((role) => role.id === "lead-vocal"); + if (!bass || !vocal) { + throw new Error("Demo fixture must contain bass and lead vocal roles"); + } + + const source = { + ...structuredClone(template), + id: "verse-source", + label: "verse" as const, + timeRange: { start: 0, end: 10 }, + roles: [{ ...bass, rehearsalPriority: "high" as const }], + partGraph: [ + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: ["bass-guitar"] } + ] + }; + const destination = { + ...structuredClone(template), + id: "chorus-destination", + label: "chorus" as const, + timeRange: { start: 10, end: 30 }, + roles: [{ ...vocal, rehearsalPriority: "medium" as const }], + partGraph: [ + { role_id: "bass-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ] + }; + + return { ...song, sections: [source, destination] }; +} diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.test.ts b/apps/desktop/src/features/workspace/firstPartHandoff.test.ts new file mode 100644 index 000000000..66638317f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { formatPartHandoffTime, resolveFirstPartHandoff } from "./firstPartHandoff"; + +type SectionLabel = "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + +type TransitionOverrides = { + id?: string; + start?: number; + end?: number; + label?: SectionLabel; + givingId?: string; + givingName?: string; + receivingId?: string; + receivingName?: string; + givingPriority?: "low" | "medium" | "high"; + receivingPriority?: "low" | "medium" | "high"; + givingActive?: boolean; + receivingActive?: boolean; + givingDestinationActive?: boolean; + handoffTo?: string[]; + handoffFrom?: string[]; +}; + +function withHandoffTransition(overrides: TransitionOverrides = {}): RehearsalSong { + const song = createDemoRehearsalSong(); + const template = song.sections[0]!; + const givingTemplate = template.roles[0]!; + const receivingTemplate = template.roles[2]!; + const givingId = overrides.givingId ?? "bass-guitar"; + const receivingId = overrides.receivingId ?? "lead-vocal"; + const givingRole = { + ...givingTemplate, + id: givingId, + name: overrides.givingName ?? "Bass Guitar", + rehearsalPriority: overrides.givingPriority ?? "high" as const + }; + const receivingRole = { + ...receivingTemplate, + id: receivingId, + name: overrides.receivingName ?? "Lead Vocal", + rehearsalPriority: overrides.receivingPriority ?? "medium" as const + }; + const source = { + ...structuredClone(template), + id: "source-section", + label: "verse" as const, + timeRange: { start: 0, end: 10 }, + roles: [givingRole], + partGraph: [ + { + role_id: givingId, + is_active: overrides.givingActive ?? true, + handoff_to: overrides.handoffTo ?? [receivingId], + handoff_from: [] + }, + { + role_id: receivingId, + is_active: false, + handoff_to: [], + handoff_from: overrides.handoffFrom ?? [givingId] + } + ] + }; + const destination = { + ...structuredClone(template), + id: overrides.id ?? "destination-section", + label: overrides.label ?? "chorus", + timeRange: { start: overrides.start ?? 10, end: overrides.end ?? 30 }, + roles: [receivingRole], + partGraph: [ + { + role_id: givingId, + is_active: overrides.givingDestinationActive ?? false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: receivingId, + is_active: overrides.receivingActive ?? true, + handoff_to: [], + handoff_from: [] + } + ] + }; + return { ...song, sections: [source, destination] }; +} + +function transitionPair(overrides: TransitionOverrides = {}) { + const song = withHandoffTransition(overrides); + return [structuredClone(song.sections[0]!), structuredClone(song.sections[1]!)] as const; +} + +describe("resolveFirstPartHandoff", () => { + it("treats the shipped one-section demo graph as non-transition evidence", () => { + expect(resolveFirstPartHandoff(createDemoRehearsalSong())).toBeNull(); + expect(formatPartHandoffTime(Number.NaN)).toBe("0:00"); + expect(formatPartHandoffTime(-4)).toBe("0:00"); + }); + + it("names the destination of the earliest corroborated activity transition", () => { + const resolved = resolveFirstPartHandoff(withHandoffTransition()); + expect(resolved?.section.id).toBe("destination-section"); + expect(resolved?.givingRole.id).toBe("bass-guitar"); + expect(resolved?.receivingRole.id).toBe("lead-vocal"); + expect(resolved?.givingName).toBe("Bass Guitar"); + expect(resolved?.receivingName).toBe("Lead Vocal"); + expect(resolved?.atSeconds).toBe(10); + expect(formatPartHandoffTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + }); + + it("does not invent a pass from buyer-visible descriptive metadata", () => { + const song = withHandoffTransition({ label: "handoff", handoffTo: [], handoffFrom: [] }); + const source = song.sections[0]!; + source.groove = "Straight eighths with a late snare feel"; + source.roles[0]!.simplification = "Stay on roots."; + source.roles[0]!.setupNote = "Keep the attack short."; + source.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup." }; + source.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + source.roles[0]!.overlapWarnings = ["Density warning."]; + source.roles[0]!.harmony = { chord: "C#m7", functionLabel: "vi pedal anchor", source: "user" }; + source.roles[0]!.harmonicExplanation = "The bass holds the vi center."; + source.roles[0]!.transpositionPlan = "Drop a whole step."; + source.roles[0]!.confidence = { + level: "high", + source: "user", + notes: "Bass Guitar hands off to Lead Vocal." + }; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("requires source-side receiving-edge corroboration", () => { + expect(resolveFirstPartHandoff(withHandoffTransition({ handoffFrom: [] }))).toBeNull(); + }); + + it("skips self-handoffs", () => { + expect( + resolveFirstPartHandoff( + withHandoffTransition({ + receivingId: "bass-guitar", + handoffTo: ["bass-guitar"], + handoffFrom: ["bass-guitar"] + }) + ) + ).toBeNull(); + }); + + it("requires the giver to deactivate and receiver to activate in the destination", () => { + expect(resolveFirstPartHandoff(withHandoffTransition({ receivingActive: false }))).toBeNull(); + expect(resolveFirstPartHandoff(withHandoffTransition({ givingDestinationActive: true }))).toBeNull(); + }); + + it("requires the giving graph node to be active in the source", () => { + expect(resolveFirstPartHandoff(withHandoffTransition({ givingActive: false }))).toBeNull(); + }); + + it("prefers the earlier destination when multiple transitions are valid", () => { + const late = transitionPair({ id: "late", start: 40, end: 56, givingId: "keys-right", givingName: "Keys" }); + const early = transitionPair({ id: "early", start: 8, end: 24, givingPriority: "low" }); + const song = withHandoffTransition(); + song.sections = [...late, ...early]; + + const resolved = resolveFirstPartHandoff(song); + expect(resolved?.section.id).toBe("early"); + expect(resolved?.givingRole.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time destination ties with locale-independent section id ordering", () => { + const umlaut = transitionPair({ id: "ä-handoff", start: 10, end: 26 }); + const ascii = transitionPair({ id: "z-handoff", start: 10, end: 26 }); + const song = withHandoffTransition(); + song.sections = [...umlaut, ...ascii]; + + expect(resolveFirstPartHandoff(song)?.section.id).toBe("z-handoff"); + }); + + it("prefers higher-priority giving roles within one source transition", () => { + const song = withHandoffTransition({ givingId: "keys-right", givingName: "Keys", givingPriority: "low" }); + const source = song.sections[0]!; + const destination = song.sections[1]!; + const highRole = { ...source.roles[0]!, id: "bass-guitar", name: "Bass Guitar", rehearsalPriority: "high" as const }; + source.roles = [source.roles[0]!, highRole]; + source.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: ["keys-right", "bass-guitar"] } + ]; + destination.partGraph = [ + { role_id: "keys-right", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "bass-guitar", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstPartHandoff(song)?.givingRole.id).toBe("bass-guitar"); + }); + + it("breaks equal-priority giving-role ties with locale-independent ids", () => { + const song = withHandoffTransition({ givingId: "ä-role", givingName: "Umlaut role", givingPriority: "high" }); + const source = song.sections[0]!; + const destination = song.sections[1]!; + const asciiRole = { ...source.roles[0]!, id: "z-role", name: "ASCII role" }; + source.roles = [source.roles[0]!, asciiRole]; + source.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: ["ä-role", "z-role"] } + ]; + destination.partGraph = [ + { role_id: "ä-role", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstPartHandoff(song)?.givingRole.id).toBe("z-role"); + }); + + it("fails closed on invalid destination timing", () => { + expect(resolveFirstPartHandoff(withHandoffTransition({ start: Number.NaN, end: 30 }))).toBeNull(); + expect(resolveFirstPartHandoff(withHandoffTransition({ start: 30, end: 10 }))).toBeNull(); + expect(resolveFirstPartHandoff(withHandoffTransition({ start: 10, end: 10 }))).toBeNull(); + expect( + resolveFirstPartHandoff( + withHandoffTransition({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for malformed roots and sparse section collections", () => { + expect(resolveFirstPartHandoff(null as never)).toBeNull(); + const song = withHandoffTransition(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("keeps a handoff unnamed when source role or graph identities are duplicated", () => { + const song = withHandoffTransition(); + const source = song.sections[0]!; + const role = source.roles[0]!; + source.roles = [role, { ...role }]; + source.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, + { role_id: "lead-vocal", is_active: false, handoff_to: [], handoff_from: [role.id] } + ]; + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("bounds displayed role names by Unicode code points", () => { + const longName = resolveFirstPartHandoff(withHandoffTransition({ givingName: "G".repeat(200) })); + expect(longName?.givingName.length).toBe(80); + + const emojiName = resolveFirstPartHandoff(withHandoffTransition({ givingName: `${"a".repeat(79)}😀tail` })); + expect(Array.from(emojiName?.givingName ?? "")).toHaveLength(80); + expect(emojiName?.givingName.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.transition.test.ts b/apps/desktop/src/features/workspace/firstPartHandoff.transition.test.ts new file mode 100644 index 000000000..91c999e62 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.transition.test.ts @@ -0,0 +1,92 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPartHandoff } from "./firstPartHandoff"; + +function activityTransition(): RehearsalSong { + const song = createDemoRehearsalSong(); + const template = song.sections[0]!; + const bass = template.roles.find((role) => role.id === "bass-guitar"); + const vocal = template.roles.find((role) => role.id === "lead-vocal"); + if (!bass || !vocal) { + throw new Error("Demo fixture must contain bass and lead vocal roles"); + } + + const source = { + ...structuredClone(template), + id: "verse-source", + label: "verse" as const, + timeRange: { start: 0, end: 10 }, + roles: [{ ...bass, rehearsalPriority: "high" as const }], + partGraph: [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar"] + } + ] + }; + const destination = { + ...structuredClone(template), + id: "chorus-destination", + label: "chorus" as const, + timeRange: { start: 10, end: 30 }, + roles: [{ ...vocal, rehearsalPriority: "medium" as const }], + partGraph: [ + { + role_id: "bass-guitar", + is_active: false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }; + + return { ...song, sections: [source, destination] }; +} + +describe("resolveFirstPartHandoff activity transition", () => { + it("names and opens the destination section for an analysis-derived handoff", () => { + const resolved = resolveFirstPartHandoff(activityTransition()); + + expect(resolved?.section.id).toBe("chorus-destination"); + expect(resolved?.givingRole.id).toBe("bass-guitar"); + expect(resolved?.receivingRole.id).toBe("lead-vocal"); + expect(resolved?.givingName).toBe("Bass Guitar"); + expect(resolved?.receivingName).toBe("Lead Vocal"); + expect(resolved?.atSeconds).toBe(10); + }); + + it("requires the source graph receiver to corroborate the outgoing edge", () => { + const song = activityTransition(); + song.sections[0]!.partGraph[1]!.handoff_from = []; + + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("requires the receiver to become active in the destination", () => { + const song = activityTransition(); + song.sections[1]!.partGraph[1]!.is_active = false; + + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); + + it("does not invent a transition for a one-section song", () => { + const song = activityTransition(); + song.sections = [song.sections[0]!]; + + expect(resolveFirstPartHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPartHandoff.ts b/apps/desktop/src/features/workspace/firstPartHandoff.ts new file mode 100644 index 000000000..bf293799d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPartHandoff.ts @@ -0,0 +1,388 @@ +import { + MAX_SECTION_TIME_SECONDS, + type PartGraphNode, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_ROLE_NAME_CHARACTERS = 80; + +/** Tonight's first part handoff: the destination section and the parts that own the pass into it. */ +export type FirstPartHandoff = { + section: RehearsalSection; + givingRole: RehearsalRole; + receivingRole: RehearsalRole; + givingName: string; + receivingName: string; + atSeconds: number; +}; + +/** Format a non-negative part-handoff time as m:ss for rehearsal copy. */ +export function formatPartHandoffTime(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 a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element 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 (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded own role name, or null when it cannot be shown. */ +function ownedRoleName(role: unknown): string | null { + if (!isRuntimeObject(role) || !hasOwnData(role, "name")) { + return null; + } + const name = (role as { name?: unknown }).name; + if (typeof name !== "string") { + return null; + } + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_ROLE_NAME_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + ownedRoleName(role) !== null && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { + 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 whether a section has safe identity, label, and timing for buyer-visible navigation. */ +function isSafeSection(section: RehearsalSection): boolean { + return ( + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ); +} + +/** 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; +} + +/** Return owned unique non-blank string ids from a dense graph-edge collection. */ +function ownedUniqueEdgeIds(value: unknown): string[] | null { + if (!isDenseRuntimeArray(value)) { + return null; + } + const ids: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + return null; + } + const trimmed = entry.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + ids.push(trimmed); + } + if (repeatedIds(ids).size > 0) { + return null; + } + return ids; +} + +/** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */ +function pickRankedRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + 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) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + 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 unique owned graph node for a role, or null when identity is untrusted. */ +function ownedGraphNode(section: RehearsalSection, roleId: string): PartGraphNode | null { + if (!hasOwnData(section, "partGraph") || !isDenseRuntimeArray(section.partGraph)) { + return null; + } + const matches = section.partGraph.filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id === roleId + ); + return matches.length === 1 ? (matches[0] ?? null) : null; +} + +/** Return whether a source receiver edge is corroborated and becomes active in the destination. */ +function isCorroboratedReceiver( + source: RehearsalSection, + destination: RehearsalSection, + givingRoleId: string, + receivingRoleId: string +): boolean { + const sourceReceiverNode = ownedGraphNode(source, receivingRoleId); + const destinationReceiverNode = ownedGraphNode(destination, receivingRoleId); + const destinationGivingNode = ownedGraphNode(destination, givingRoleId); + if ( + sourceReceiverNode === null || + destinationReceiverNode === null || + destinationGivingNode === null || + !hasOwnData(sourceReceiverNode, "handoff_from") || + !hasOwnData(sourceReceiverNode, "is_active") || + sourceReceiverNode.is_active !== false || + !hasOwnData(destinationReceiverNode, "is_active") || + destinationReceiverNode.is_active !== true || + !hasOwnData(destinationGivingNode, "is_active") || + destinationGivingNode.is_active !== false + ) { + return false; + } + const incoming = ownedUniqueEdgeIds(sourceReceiverNode.handoff_from); + return incoming !== null && incoming.includes(givingRoleId); +} + +/** Resolve a corroborated source-to-destination pass after the runtime root passes structural checks. */ +function resolveSafeFirstPartHandoff( + song: RehearsalSong, + activeRole: string | null +): FirstPartHandoff | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates: FirstPartHandoff[] = []; + for (let sectionIndex = 0; sectionIndex < song.sections.length - 1; sectionIndex += 1) { + const source = song.sections[sectionIndex]; + const destination = song.sections[sectionIndex + 1]; + if (!source || !destination || !isSafeSection(source) || !isSafeSection(destination)) { + continue; + } + + const sourceActiveRoles = rankedActiveRoles(source); + const destinationActiveRoles = rankedActiveRoles(destination); + const destinationById = new Map(destinationActiveRoles.map((role) => [role.id, role])); + const passes = sourceActiveRoles.flatMap((givingRole) => { + const givingNode = ownedGraphNode(source, givingRole.id); + if ( + givingNode === null || + !hasOwnData(givingNode, "handoff_to") || + !hasOwnData(givingNode, "is_active") || + givingNode.is_active !== true + ) { + return []; + } + const outgoing = ownedUniqueEdgeIds(givingNode.handoff_to); + if (outgoing === null) { + return []; + } + const receivingRoles = outgoing + .filter((roleId) => roleId !== givingRole.id) + .map((roleId) => destinationById.get(roleId)) + .filter((role): role is RehearsalRole => role !== undefined) + .filter((receivingRole) => + isCorroboratedReceiver(source, destination, givingRole.id, receivingRole.id) + ) + .filter( + (receivingRole) => + activeRole === null || + givingRole.id === activeRole || + receivingRole.id === activeRole + ); + const receivingRole = pickRankedRole(receivingRoles); + return receivingRole ? [{ givingRole, receivingRole }] : []; + }); + const chosen = pickRankedRole(passes.map((pass) => pass.givingRole)); + const matched = chosen ? passes.find((pass) => pass.givingRole.id === chosen.id) : undefined; + if (!matched) { + continue; + } + const givingName = ownedRoleName(matched.givingRole); + const receivingName = ownedRoleName(matched.receivingRole); + if (!givingName || !receivingName) { + continue; + } + candidates.push({ + section: destination, + givingRole: matched.givingRole, + receivingRole: matched.receivingRole, + givingName, + receivingName, + atSeconds: destination.timeRange.start + }); + } + + candidates.sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.section.id, right.section.id); + }); + return candidates[0] ?? null; +} + +/** Return the first named part handoff, optionally scoped to a selected giving or receiving role. */ +export function resolveFirstPartHandoff( + song: RehearsalSong, + activeRole: string | null = null +): FirstPartHandoff | null { + try { + return resolveSafeFirstPartHandoff(song, activeRole); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..9dbf5d8d6 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,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-part-handoff next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstPartHandoffOpenAction")).toBe("{at} {from} 핸드오프 위치 열기"); + expect(t("firstPartHandoffBody")).toBe("{at} {section}에서 {from} 파트가 {to} 파트로 넘깁니다."); + expect(t("firstPartHandoffArmed")).toBe("{at}에서 {from} 파트에서 {to} 파트로 넘긴 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 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,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(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 d803a765e..5c2220d10 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstPartHandoffLabel": "Tonight's first part handoff", + "firstPartHandoffOpenAction": "Open {from} handoff at {at}", + "firstPartHandoffBody": "{from} still hands off to {to} in the {section} at {at}.", + "firstPartHandoffArmed": "Lock that pass from {from} to {to} at {at} before the room starts.", + "firstPartHandoffUnavailable": "Nothing still has a part handoff. Stay on tonight's map until a part owns a rehearsal-facing pass.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..991e59930 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstPartHandoffLabel": "오늘 첫 파트 핸드오프", + "firstPartHandoffOpenAction": "{at} {from} 핸드오프 위치 열기", + "firstPartHandoffBody": "{at} {section}에서 {from} 파트가 {to} 파트로 넘깁니다.", + "firstPartHandoffArmed": "{at}에서 {from} 파트에서 {to} 파트로 넘긴 다음 합주를 시작하세요.", + "firstPartHandoffUnavailable": "넘기는 파트가 없습니다. 합주용 핸드오프가 있는 파트가 생길 때까지 오늘 맵에 머무르세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..0da7a425d 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 Part Handoff Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstPartHandoffCallout.tsx` | Treat the source section's active giving node and owned `handoff_to` plus the source receiver node's `handoff_from` as transition evidence. Require the giver to deactivate and receiver to become an active named role in the immediately following destination section. Show the destination label/start time and make Open scroll to that renderer-owned destination index. One-section same-state graphs, inherited/accessor/sparse metadata, descriptive copy, or a labeled `handoff` form cannot establish a pass. Keep the unavailable state guidance-only. Distinct from labeled-form handoff (#937) and Part Handoff Map visualization (#850). | | 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-part-handoff-navigation.md b/docs/doctoring/reduced-motion-first-part-handoff-navigation.md new file mode 100644 index 000000000..ef2242051 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-part-handoff-navigation.md @@ -0,0 +1,5 @@ +# Reduced-motion first part-handoff navigation + +When `prefers-reduced-motion: reduce` matches, `FirstPartHandoffCallout` scrolls the renderer-owned destination song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +The handoff is a source-to-next-section transition, not a same-section relationship: the source graph supplies the active giver plus corroborated `handoff_to` / `handoff_from` evidence, while the immediately following destination must show the giver deactivated and receiver active. Open names the giving part, receiving part, destination label, and destination start time, then navigates by the renderer-owned destination index. Analysis `section.id` is never DOM-ID authority. This map next-action is distinct from the labeled `handoff` form (#937) and the Part Handoff Map visualization (#850). diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..82ed7fdd2 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -83,7 +83,7 @@ def extract( extraction_method = ( "Extracted roles from real stem activity detection." if activity_maps is not None - else "Extracted roles and computed handoffs." + else "Extracted roles using heuristic fallback; handoffs unavailable." ) return { @@ -380,7 +380,7 @@ def _build_topology( is_first: bool, roles: dict[str, RehearsalRole], ) -> SectionRoleTopology: - """Construct the topology including active roles and the part graph.""" + """Construct heuristic topology without transition handoff authority.""" active_roles = [roles["bass"], roles["acoustic_guitar"]] part_graph: list[PartGraphNode] = [ @@ -417,11 +417,6 @@ def _build_topology( }, ] ) - for node in part_graph: - if node["role_id"] == "bass-guitar": - node["handoff_to"].append("lead-vocal") - elif node["role_id"] == "lead-vocal": - node["handoff_from"].append("bass-guitar") else: part_graph.extend( [ diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..4e6751f1b 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -34,7 +34,7 @@ def test_cue_anchor_kind_enum() -> None: def test_role_extractor_basic() -> None: - """Test that RoleExtractor returns a valid topology structure.""" + """Test that heuristic RoleExtractor output does not invent handoffs.""" extractor = RoleExtractor() sections = [{"id": "intro"}, {"id": "verse-1"}] @@ -43,6 +43,9 @@ def test_role_extractor_basic() -> None: assert "topologies" in result assert "extraction_notes" in result + assert result["extraction_notes"] == ( + "Extracted roles using heuristic fallback; handoffs unavailable." + ) assert len(result["topologies"]) == 2 # Check intro section @@ -61,9 +64,9 @@ def test_role_extractor_basic() -> None: intro_graph = intro_topology["part_graph"] graph_by_role = {n["role_id"]: n for n in intro_graph} - # Check handoff relation - assert "lead-vocal" in graph_by_role["bass-guitar"]["handoff_to"] - assert "bass-guitar" in graph_by_role["lead-vocal"]["handoff_from"] + # Heuristic fallback cannot prove a cross-section transition. + assert graph_by_role["bass-guitar"]["handoff_to"] == [] + assert graph_by_role["lead-vocal"]["handoff_from"] == [] # Check verse-1 section (only bass) verse_topology = result["topologies"][1]