From 912b93f5e75da7a1dd3aab4b67fbf7cf5d77907d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:21:02 +0000 Subject: [PATCH 01/20] feat(workspace): guide tonight's first labeled handoff Name the earliest labeled handoff cut so the room can catch the pass. Workspace opens that map section; the player Hear action exists only when playback supplies a seek callback. Do not invent a pass from stop, pickup, or graph edges on another form label. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 57 ++++++ apps/desktop/src/features/player/index.tsx | 24 ++- ...irstHandoffCallout.reduced-motion.test.tsx | 62 +++++++ .../workspace/FirstHandoffCallout.test.tsx | 147 ++++++++++++++++ .../workspace/FirstHandoffCallout.tsx | 142 +++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 44 +++++ .../src/features/workspace/Workspace.tsx | 3 + .../firstLabeledHandoff.activity-type.test.ts | 46 +++++ ...abeledHandoff.duplicate-identities.test.ts | 65 +++++++ ...rstLabeledHandoff.inactive-labeled.test.ts | 34 ++++ ...Handoff.invalid-holder-collections.test.ts | 31 ++++ ...ledHandoff.invalid-holder-elements.test.ts | 35 ++++ ...irstLabeledHandoff.invalid-role-id.test.ts | 74 ++++++++ ...Handoff.invalid-section-collection.test.ts | 26 +++ ...tLabeledHandoff.invalid-section-id.test.ts | 24 +++ ...tLabeledHandoff.invalid-time-range.test.ts | 65 +++++++ .../workspace/firstLabeledHandoff.test.ts | 131 ++++++++++++++ .../features/workspace/firstLabeledHandoff.ts | 164 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 9 + 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-handoff-navigation.md | 14 ++ 27 files changed, 1219 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHandoffCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHandoffCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.activity-type.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.duplicate-identities.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.inactive-labeled.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-collections.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-role-id.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-id.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-time-range.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.ts create mode 100644 docs/doctoring/reduced-motion-first-handoff-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..26e7462c9 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 handoff with the holding part when an active role is corroborated, the labeled pass, 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..c74f28de0 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 handoff must name the holding part when corroborated, the labeled pass, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..63a83c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first labeled handoff on the workspace and player so the room can catch the pass; 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..a8ed5458e 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 handoff so the room can catch the pass; 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..ef70d33a8 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,57 @@ +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 songWithHandoff() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + handoff.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + handoff.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + 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 handoff from this player.") + ).toBeTruthy(); + }); + + it("keeps the handoff hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal pass at 0:22" })).toBeNull(); + expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy(); + }); + + it("delegates the handoff hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pass at 0:22" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(22); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..d5d950cb6 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,14 +1,22 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstHandoffCallout } from "../workspace/FirstHandoffCallout"; +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; +}; + +/** Player surface that names tonight's first labeled handoff 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("firstHandoffNeedsSong")}

); } @@ -16,12 +24,14 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul return (

{title}

+
@@ -31,16 +41,16 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
- {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..d8d56e7d6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.reduced-motion.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHandoffCallout } from "./FirstHandoffCallout"; + +function songWithHandoff() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + handoff.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + handoff.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + return song; +} + +describe("FirstHandoffCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal("matchMedia", matchMedia); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" })); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx new file mode 100644 index 000000000..3899be6d1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstHandoffCallout } from "./FirstHandoffCallout"; + +function songWithHandoff() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + handoff.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + handoff.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstHandoffCallout", () => { + it("names the first handoff 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 handoff at 0:22" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch Lead Vocal's pass at 0:22. Take the next part./)).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 handoff at 0:22" })); + + expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy(); + expect(screen.queryByText(/Catch Lead Vocal's pass at 0:22. Take the next part./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearHandoff = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" })); + expect(onHearHandoff).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 = songWithHandoff(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first handoff changes or returns later", () => { + const initialSong = songWithHandoff(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal handoff at 0:22" })); + expect(screen.getByText(/Catch Lead Vocal's pass at 0:22. Take the next part./)).toBeTruthy(); + + const nextSong = songWithHandoff(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 30, end: 32 }; + rerender(); + expect(screen.getByText("Lead Vocal passes the handoff at 0:30.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable handoff guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.") + ).toBeTruthy(); + }); + + it("names a band-wide pass when no part holds the handoff", () => { + const song = songWithHandoff(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first handoff at 0:22" })).toBeTruthy(); + expect(screen.getByText("The band passes the handoff at 0:22.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearHandoff = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal pass at 0:22" })); + expect(onHearHandoff).toHaveBeenCalledWith(22); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx new file mode 100644 index 000000000..025ef9b38 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatHandoffTime, resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +/** Props for the first-handoff rehearsal callout. */ +export interface FirstHandoffCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearHandoff?: (atSeconds: number) => void; +} + +type HandoffCopyValues = Readonly>; + +type HeardHandoff = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate handoff placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatHandoffCopy(template: string, values: HandoffCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof HandoffCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredHandoffScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled handoff and offer only an action that the current surface can execute. */ +export function FirstHandoffCallout({ + song, + actionMode = "workspace-scroll", + onHearHandoff +}: FirstHandoffCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const handoff = resolveFirstLabeledHandoff(song); + const handoffSectionIndex = handoff ? song.sections.indexOf(handoff.section) : -1; + const [heardHandoff, setHeardHandoff] = useState(null); + + useEffect(() => { + setHeardHandoff(null); + }, [song.id, handoffSectionIndex, handoff?.section.id, handoff?.holdingRole?.id, handoff?.atSeconds]); + + if (!handoff) { + return ( + + ); + } + + const heard = + heardHandoff?.songId === song.id && + heardHandoff.sectionId === handoff.section.id && + heardHandoff.sectionIndex === handoffSectionIndex && + heardHandoff.holdingRoleId === (handoff.holdingRole?.id ?? null) && + heardHandoff.atSeconds === handoff.atSeconds; + const at = formatHandoffTime(handoff.atSeconds); + const copyValues: HandoffCopyValues = { + role: handoff.holdingRole?.name ?? "", + section: handoff.section.label, + at + }; + const hasRole = handoff.holdingRole !== null; + const actionLabel = formatHandoffCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstHandoffAction" + : "firstHandoffActionBand" + : hasRole + ? "firstHandoffOpenAction" + : "firstHandoffOpenActionBand" + ), + copyValues + ); + const body = formatHandoffCopy(t(hasRole ? "firstHandoffBody" : "firstHandoffBodyBand"), copyValues); + const armed = formatHandoffCopy(t(hasRole ? "firstHandoffArmed" : "firstHandoffArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearHandoff === "function"; + /** Record completion only after the owning surface has executed the selected handoff action. */ + const markHandoffActionComplete = () => { + setHeardHandoff({ + songId: song.id, + sectionId: handoff.section.id, + sectionIndex: handoffSectionIndex, + holdingRoleId: handoff.holdingRole?.id ?? null, + atSeconds: handoff.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..49cef52c7 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 handoff as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + handoff.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + handoff.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + + 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 + }); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal handoff at 0:22" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch Lead Vocal's pass at 0:22. Take the next part./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..c8a31743f 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 { FirstHandoffCallout } from "./FirstHandoffCallout"; 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/firstLabeledHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.activity-type.test.ts new file mode 100644 index 000000000..08e1806b3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.activity-type.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstLabeledHandoff activity-type authority", () => { + it("does not treat a string false flag as an active handoff holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "handoff-1"; + section.label = "handoff"; + section.timeRange = { start: 22, end: 24 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstLabeledHandoff(song)?.holdingRole?.id).toBe("active-bass"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.duplicate-identities.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.duplicate-identities.test.ts new file mode 100644 index 000000000..eea3bef48 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.duplicate-identities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function songWithHandoff() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + const role = { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high" as const + }; + + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + handoff.roles = [role]; + handoff.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + return { song, handoff, role }; +} + +describe("resolveFirstLabeledHandoff ambiguous identities", () => { + it("keeps a band-wide pass when a handoff repeats one role identity", () => { + const { song, handoff, role } = songWithHandoff(); + handoff.roles = [role, { ...role, name: "Duplicate Bass" }]; + + const result = resolveFirstLabeledHandoff(song); + + expect(result?.section).toBe(handoff); + expect(result?.holdingRole).toBeNull(); + }); + + it("keeps a band-wide pass when a handoff repeats one graph-node identity", () => { + const { song, handoff, role } = songWithHandoff(); + handoff.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: role.id, + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + + const result = resolveFirstLabeledHandoff(song); + + expect(result?.section).toBe(handoff); + expect(result?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.inactive-labeled.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.inactive-labeled.test.ts new file mode 100644 index 000000000..25bf33e04 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.inactive-labeled.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +describe("resolveFirstLabeledHandoff inactive labeled holder", () => { + it("does not name an inactive labeled role as the handoff holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "handoff-1"; + section.label = "handoff"; + section.timeRange = { start: 22, end: 24 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const handoff = resolveFirstLabeledHandoff(song); + expect(handoff?.section.id).toBe("handoff-1"); + expect(handoff?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-collections.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-collections.test.ts new file mode 100644 index 000000000..4b293b068 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-collections.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function songWithHandoff() { + const song = createDemoRehearsalSong(); + const handoff = structuredClone(song.sections[0]!); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [handoff]; + return { song, handoff }; +} + +describe("resolveFirstLabeledHandoff runtime holder collections", () => { + it("keeps the pass band-wide when runtime roles are not an array", () => { + const { song, handoff } = songWithHandoff(); + handoff.roles = null as unknown as typeof handoff.roles; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + }); + + it("keeps the pass band-wide when runtime partGraph is not an array", () => { + const { song, handoff } = songWithHandoff(); + handoff.partGraph = null as unknown as typeof handoff.partGraph; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts new file mode 100644 index 000000000..5650a2a6b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function songWithHandoff() { + const song = createDemoRehearsalSong(); + const handoff = structuredClone(song.sections[0]!); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [handoff]; + return { song, handoff }; +} + +describe("resolveFirstLabeledHandoff runtime holder elements", () => { + it("keeps the pass band-wide when runtime roles contain a non-object element", () => { + for (const malformedRole of [null, 42]) { + const { song, handoff } = songWithHandoff(); + handoff.roles = [malformedRole] as unknown as typeof handoff.roles; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + } + }); + + it("keeps the pass band-wide when runtime partGraph contains a non-object element", () => { + for (const malformedNode of [null, 42]) { + const { song, handoff } = songWithHandoff(); + handoff.partGraph = [malformedNode] as unknown as typeof handoff.partGraph; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..77076c400 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-role-id.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +describe("resolveFirstLabeledHandoff runtime role identity", () => { + it("ignores an active handoff role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "handoff-1"; + section.label = "handoff"; + section.timeRange = { start: 22, end: 24 }; + + const safeRole = { + ...section.roles[2]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "high" as const + }; + const malformedRole = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole?.id).toBe("safe-vocal"); + }); + + it("does not surface a malformed runtime role name as the holding part", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "handoff-1"; + section.label = "handoff"; + section.timeRange = { start: 22, end: 24 }; + section.roles = [ + { + ...section.roles[0]!, + id: "malformed-name", + name: { unsafe: "object" } as unknown as string, + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "malformed-name", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts new file mode 100644 index 000000000..8639b6ee2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function songWithRuntimeSections(sections: unknown): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = sections as RehearsalSong["sections"]; + return song; +} + +describe("resolveFirstLabeledHandoff runtime section collection", () => { + it("fails closed when the runtime section collection is not an array", () => { + const song = songWithRuntimeSections(null); + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements instead of dereferencing them", () => { + const song = songWithRuntimeSections([null, 42]); + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-id.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-id.test.ts new file mode 100644 index 000000000..ccb9398da --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-id.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function malformedHandoff(sectionId: unknown) { + const song = createDemoRehearsalSong(); + const handoff = structuredClone(song.sections[0]!); + handoff.id = sectionId as string; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [handoff]; + return song; +} + +describe("resolveFirstLabeledHandoff runtime section identity", () => { + it("rejects handoff sections whose runtime id is not a non-empty string", () => { + for (const invalidId of [42, " "]) { + const song = malformedHandoff(invalidId); + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-time-range.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-time-range.test.ts new file mode 100644 index 000000000..1f5250ca4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-time-range.test.ts @@ -0,0 +1,65 @@ +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +describe("resolveFirstLabeledHandoff runtime time range", () => { + it("rejects a handoff whose runtime timeRange is not an object", () => { + const song = createDemoRehearsalSong(); + const handoff = structuredClone(song.sections[0]!); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = null as unknown as typeof handoff.timeRange; + song.sections = [handoff]; + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("skips a zero-length handoff window and selects the next valid pass", () => { + const song = createDemoRehearsalSong(); + const zeroLength = structuredClone(song.sections[0]!); + zeroLength.id = "handoff-zero-length"; + zeroLength.label = "handoff"; + zeroLength.timeRange = { start: 10, end: 10 }; + + const valid = structuredClone(song.sections[0]!); + valid.id = "handoff-valid"; + valid.label = "handoff"; + valid.timeRange = { start: 22, end: 24 }; + song.sections = [zeroLength, valid]; + + expect(resolveFirstLabeledHandoff(song)?.section.id).toBe("handoff-valid"); + }); + + it("skips a handoff whose runtime window exceeds the shared u32 timing contract", () => { + const song = createDemoRehearsalSong(); + const overflowing = structuredClone(song.sections[0]!); + overflowing.id = "handoff-overflow"; + overflowing.label = "handoff"; + overflowing.timeRange = { start: 10, end: MAX_SECTION_TIME_SECONDS + 1 }; + + const valid = structuredClone(song.sections[0]!); + valid.id = "handoff-valid"; + valid.label = "handoff"; + valid.timeRange = { start: 22, end: 24 }; + song.sections = [overflowing, valid]; + + expect(resolveFirstLabeledHandoff(song)?.section.id).toBe("handoff-valid"); + }); + + it("skips a handoff whose runtime window uses fractional seconds outside the shared timing contract", () => { + const song = createDemoRehearsalSong(); + const fractional = structuredClone(song.sections[0]!); + fractional.id = "handoff-fractional"; + fractional.label = "handoff"; + fractional.timeRange = { start: 10.5, end: 11.5 }; + + const valid = structuredClone(song.sections[0]!); + valid.id = "handoff-valid"; + valid.label = "handoff"; + valid.timeRange = { start: 22, end: 24 }; + song.sections = [fractional, valid]; + + expect(resolveFirstLabeledHandoff(song)?.section.id).toBe("handoff-valid"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.test.ts new file mode 100644 index 000000000..de96e0998 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatHandoffTime, resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function withHandoffSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + handoff.id = overrides.id ?? "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: overrides.start ?? 22, end: overrides.end ?? 24 }; + const roleId = overrides.roleId ?? "lead-vocal"; + handoff.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + handoff.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + return song; +} + +describe("resolveFirstLabeledHandoff", () => { + it("returns null when the demo song has no labeled handoff", () => { + expect(resolveFirstLabeledHandoff(createDemoRehearsalSong())).toBeNull(); + expect(formatHandoffTime(Number.NaN)).toBe("0:00"); + expect(formatHandoffTime(-4)).toBe("0:00"); + }); + + it("does not invent a handoff from a stop, pickup, or graph edge on another form label", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const pickup = structuredClone(verse); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + verse.partGraph = [ + { + role_id: verse.roles[0]!.id, + is_active: true, + handoff_to: [verse.roles[1]!.id], + handoff_from: [] + } + ]; + song.sections = [verse, pickup, stop]; + + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("picks the earliest labeled handoff and the part that gives the pass", () => { + const song = withHandoffSection({ start: 22, end: 24 }); + const handoff = resolveFirstLabeledHandoff(song); + + expect(handoff?.section.id).toBe("handoff-1"); + expect(handoff?.holdingRole?.id).toBe("lead-vocal"); + expect(handoff?.atSeconds).toBe(22); + expect(formatHandoffTime(handoff?.atSeconds ?? -1)).toBe("0:22"); + }); + + it("prefers the earlier of two labeled handoffs", () => { + const song = withHandoffSection({ id: "handoff-late", start: 40, end: 42 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "handoff-early"; + earlier.timeRange = { start: 14, end: 16 }; + earlier.roles = [ + { + ...verse.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]!, song.sections[1]!, earlier]; + + const handoff = resolveFirstLabeledHandoff(song); + expect(handoff?.section.id).toBe("handoff-early"); + expect(handoff?.holdingRole?.id).toBe("bass-guitar"); + expect(handoff?.atSeconds).toBe(14); + }); + + it("keeps a band-wide pass when no active ranked role holds it", () => { + const song = withHandoffSection({ isActive: false }); + const handoff = resolveFirstLabeledHandoff(song); + expect(handoff?.section.id).toBe("handoff-1"); + expect(handoff?.holdingRole).toBeNull(); + expect(handoff?.atSeconds).toBe(22); + }); + + it("skips a handoff whose rehearsal window is unbounded", () => { + const song = withHandoffSection({ start: Number.NaN, end: 24 }); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("skips a handoff whose end precedes its start", () => { + const song = withHandoffSection({ start: 24, end: 10 }); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts new file mode 100644 index 000000000..3954adba1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts @@ -0,0 +1,164 @@ +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 handoff: the earliest pass and the part that gives it. */ +export type FirstLabeledHandoff = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative handoff time as m:ss for rehearsal copy. */ +export function formatHandoffTime(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}`; +} + +/** 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 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 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 left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!Array.isArray(section.roles) || !Array.isArray(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 handoff, or null when no safe pass remains. */ +export function resolveFirstLabeledHandoff(song: RehearsalSong): FirstLabeledHandoff | null { + if (!Array.isArray(song.sections)) { + return null; + } + + const handoffSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "handoff" && + 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 left.id.localeCompare(right.id); + }); + + const section = handoffSections[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..6b153e1a4 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -74,5 +74,14 @@ describe("i18n", () => { koDictionary.appSubtitle = originalSubtitle; } }); + + it("keeps first-handoff keys in both baseline locales", () => { + const tEn = createTranslator("en"); + const tKo = createTranslator("ko"); + expect(tEn("firstHandoffLabel")).toBe("Tonight's first handoff"); + expect(tKo("firstHandoffLabel")).toBe("오늘 첫 핸드오프"); + expect(tEn("firstHandoffOpenAction")).toContain("{role}"); + expect(tKo("firstHandoffOpenAction")).toContain("{role}"); + }); }); }); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..a3b954c8b 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", + "firstHandoffLabel": "Tonight's first handoff", + "firstHandoffAction": "Hear {role} pass at {at}", + "firstHandoffActionBand": "Hear the first handoff at {at}", + "firstHandoffOpenAction": "Open {role} handoff at {at}", + "firstHandoffOpenActionBand": "Open the first handoff at {at}", + "firstHandoffBody": "{role} passes the {section} at {at}.", + "firstHandoffBodyBand": "The band passes the {section} at {at}.", + "firstHandoffArmed": "Catch {role}'s pass at {at}. Take the next part.", + "firstHandoffArmedBand": "Catch the pass at {at}. Take the next part.", + "firstHandoffUnavailable": "No handoff yet. Stay on tonight's map until a pass is marked.", + "firstHandoffNeedsSong": "Analyze tonight's song first, then hear the first handoff from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..12535e07f 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": "진척도 증가", + "firstHandoffLabel": "오늘 첫 핸드오프", + "firstHandoffAction": "{at}에 {role} 패스 듣기", + "firstHandoffActionBand": "{at} 첫 핸드오프 듣기", + "firstHandoffOpenAction": "{at} {role} 핸드오프 위치 열기", + "firstHandoffOpenActionBand": "{at} 첫 핸드오프 위치 열기", + "firstHandoffBody": "{role}이 {at} {section}에서 넘깁니다.", + "firstHandoffBodyBand": "밴드가 {at} {section}에서 넘깁니다.", + "firstHandoffArmed": "{at}에서 {role} 패스를 받으세요. 다음 파트를 잡으세요.", + "firstHandoffArmedBand": "{at}에서 패스를 받으세요. 다음 파트를 잡으세요.", + "firstHandoffUnavailable": "아직 핸드오프가 없습니다. 패스가 표시될 때까지 오늘 지도에 머무르세요.", + "firstHandoffNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 핸드오프를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..2223ac9d4 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 Handoff Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstHandoffCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `handoff` pass, and the time. Do not invent a pass from `stop`, `pickup`, or graph `handoff_to`/`handoff_from` edges on another form label. `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 `onHearHandoff` exists and delegates the exact handoff second to that callback. Keep the unavailable state guidance-only. Distinct from import-handoff #740 and Part Handoff Map #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-handoff-navigation.md b/docs/doctoring/reduced-motion-first-handoff-navigation.md new file mode 100644 index 000000000..1d24ff165 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-handoff-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-handoff navigation + +Workspace map navigation for tonight's first labeled handoff follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstHandoffCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Handoff resolution, action-mode authority, and analysis-id isolation stay unchanged. Graph `handoff_to` / `handoff_from` edges on another form label never invent a pass. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- 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 151feab1bb0fbf09b56bc7bd4d2ed13a969c626c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:22:17 -0700 Subject: [PATCH 02/20] test(workspace): reject sparse handoff collections --- ...tLabeledHandoff.sparse-collections.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstLabeledHandoff.sparse-collections.test.ts diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.sparse-collections.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.sparse-collections.test.ts new file mode 100644 index 000000000..14867456f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.sparse-collections.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; + +function withValidHandoff() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const handoff = structuredClone(verse); + const role = structuredClone(verse.roles[0]!); + role.id = "handoff-bass"; + role.name = "Handoff Bass"; + role.rehearsalPriority = "high"; + handoff.id = "handoff-sparse-boundary"; + handoff.label = "handoff"; + handoff.timeRange = { start: 20, end: 22 }; + handoff.roles = [role]; + handoff.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, handoff]; + return song; +} + +describe("first labeled handoff dense-array boundary", () => { + it("rejects a sparse section collection instead of skipping missing evidence", () => { + const song = withValidHandoff(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("keeps the pass band-wide when the role collection is sparse", () => { + const song = withValidHandoff(); + const handoff = song.sections[1]!; + const sparseRoles: typeof handoff.roles = new Array(2); + sparseRoles[1] = handoff.roles[0]!; + handoff.roles = sparseRoles; + + const result = resolveFirstLabeledHandoff(song); + expect(result?.section.id).toBe("handoff-sparse-boundary"); + expect(result?.holdingRole).toBeNull(); + }); + + it("keeps the pass band-wide when the part graph is sparse", () => { + const song = withValidHandoff(); + const handoff = song.sections[1]!; + const sparseGraph: typeof handoff.partGraph = new Array(2); + sparseGraph[1] = handoff.partGraph[0]!; + handoff.partGraph = sparseGraph; + + const result = resolveFirstLabeledHandoff(song); + expect(result?.section.id).toBe("handoff-sparse-boundary"); + expect(result?.holdingRole).toBeNull(); + }); +}); From 658f5c0af55787882bc4899b331a586838ab52a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:23:09 -0700 Subject: [PATCH 03/20] fix(workspace): reject sparse handoff evidence --- .../features/workspace/firstLabeledHandoff.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts index 3954adba1..7326bf652 100644 --- a/apps/desktop/src/features/workspace/firstLabeledHandoff.ts +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts @@ -29,6 +29,23 @@ 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 ( @@ -91,7 +108,7 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { /** Return ranked roles whose unique graph node is explicitly active. */ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { - if (!Array.isArray(section.roles) || !Array.isArray(section.partGraph)) { + if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { return []; } @@ -131,7 +148,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled handoff, or null when no safe pass remains. */ export function resolveFirstLabeledHandoff(song: RehearsalSong): FirstLabeledHandoff | null { - if (!Array.isArray(song.sections)) { + if (!isDenseRuntimeArray(song.sections)) { return null; } From 048dbfb4a80711cd5e79549fd075b7c5bdacc900 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:27:04 -0700 Subject: [PATCH 04/20] test(player): fail closed on malformed handoff sections --- .../src/features/player/index.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index ef70d33a8..79101d321 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -54,4 +54,36 @@ describe("PlayerFeature", () => { expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); expect(onPlayFromSeconds).toHaveBeenCalledWith(22); }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithHandoff(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithHandoff(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithHandoff(); + song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); }); From 9cfb68f490df2d3a529b6fa0eac89278718a5005 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:28:05 -0700 Subject: [PATCH 05/20] fix(player): contain malformed handoff sections --- apps/desktop/src/features/player/index.tsx | 45 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index d5d950cb6..6a6ac9610 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,4 +1,9 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; import { FirstHandoffCallout } from "../workspace/FirstHandoffCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; @@ -8,6 +13,38 @@ type PlayerFeatureProps = { 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 handoff and delegates playback to the owning player. */ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { const t = createTranslator(detectPreferredLocale()); @@ -21,6 +58,8 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP ); } + const sections = playerSummarySections(song); + return (

{title}

@@ -37,11 +76,11 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
{song.title} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section, sectionIndex) => ( + {sections.map((section, sectionIndex) => ( Date: Tue, 18 Aug 2026 18:30:05 -0700 Subject: [PATCH 06/20] test(i18n): localize handoff form copy --- .../workspace/FirstHandoffCallout.test.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx index 3899be6d1..71ecd18e6 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { FirstHandoffCallout } from "./FirstHandoffCallout"; function songWithHandoff() { @@ -47,6 +47,10 @@ function appendSongStructureTarget() { } describe("FirstHandoffCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("names the first handoff as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); @@ -144,4 +148,15 @@ describe("FirstHandoffCallout", () => { expect(screen.queryByRole("button")).toBeNull(); expect(screen.getByText("Lead Vocal passes the handoff at 0:22.")).toBeTruthy(); }); + + it("localizes the handoff form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithHandoff(); + song.sections[1]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("리드 보컬이 0:22 핸드오프에서 넘깁니다.")).toBeTruthy(); + expect(screen.queryByText(/handoff에서/)).toBeNull(); + }); }); From cda8be65e1add308128f9a7c536daaf7be876ae8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:32:55 -0700 Subject: [PATCH 07/20] feat(i18n): localize handoff form labels --- apps/desktop/src/i18n/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..a2acf6b66 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: { handoff: "handoff" }, + ko: { handoff: "핸드오프" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for a section form label, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label] ?? label; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { From b093e9f94d5804a068f3998a0703b5d041923dd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:34:05 -0700 Subject: [PATCH 08/20] fix(i18n): keep handoff form copy localized --- .../src/features/workspace/FirstHandoffCallout.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx index 025ef9b38..c2ea9e812 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; import { formatHandoffTime, resolveFirstLabeledHandoff } from "./firstLabeledHandoff"; /** Props for the first-handoff rehearsal callout. */ @@ -43,7 +47,8 @@ export function FirstHandoffCallout({ actionMode = "workspace-scroll", onHearHandoff }: FirstHandoffCalloutProps) { - const t = createTranslator(detectPreferredLocale()); + const locale = detectPreferredLocale(); + const t = createTranslator(locale); const handoff = resolveFirstLabeledHandoff(song); const handoffSectionIndex = handoff ? song.sections.indexOf(handoff.section) : -1; const [heardHandoff, setHeardHandoff] = useState(null); @@ -74,7 +79,7 @@ export function FirstHandoffCallout({ const at = formatHandoffTime(handoff.atSeconds); const copyValues: HandoffCopyValues = { role: handoff.holdingRole?.name ?? "", - section: handoff.section.label, + section: translateSectionFormLabel(locale, handoff.section.label), at }; const hasRole = handoff.holdingRole !== null; From fb9e9d96fb4ccdbb707aa8f653b0e830b485ffa6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:35:59 -0700 Subject: [PATCH 09/20] test(workspace): reject malformed handoff song roots --- ...dHandoff.invalid-section-collection.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts index 8639b6ee2..4ef261956 100644 --- a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts @@ -9,7 +9,25 @@ function songWithRuntimeSections(sections: unknown): RehearsalSong { return song; } +function runtimeSong(value: unknown): RehearsalSong { + return value as RehearsalSong; +} + describe("resolveFirstLabeledHandoff runtime section collection", () => { + it("fails closed when the runtime song root is null", () => { + const song = runtimeSong(null); + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + + it("fails closed when the runtime song root is primitive", () => { + const song = runtimeSong(42); + + expect(() => resolveFirstLabeledHandoff(song)).not.toThrow(); + expect(resolveFirstLabeledHandoff(song)).toBeNull(); + }); + it("fails closed when the runtime section collection is not an array", () => { const song = songWithRuntimeSections(null); From 197edd312ef2fe6b97dcefe3341ff4268ea74ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:36:41 -0700 Subject: [PATCH 10/20] fix(workspace): reject malformed handoff song roots --- apps/desktop/src/features/workspace/firstLabeledHandoff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts index 7326bf652..6f34d7fd3 100644 --- a/apps/desktop/src/features/workspace/firstLabeledHandoff.ts +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts @@ -148,7 +148,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled handoff, or null when no safe pass remains. */ export function resolveFirstLabeledHandoff(song: RehearsalSong): FirstLabeledHandoff | null { - if (!isDenseRuntimeArray(song.sections)) { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { return null; } From b80c2b8b2fbc14bdf8b26008993590c32a596fbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:37:38 -0700 Subject: [PATCH 11/20] test(workspace): contain malformed handoff song roots --- ...irstHandoffCallout.invalid-song-root.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstHandoffCallout.invalid-song-root.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.invalid-song-root.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.invalid-song-root.test.tsx new file mode 100644 index 000000000..c96fb294b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.invalid-song-root.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstHandoffCallout } from "./FirstHandoffCallout"; + +/** Cast runtime input through the static song contract to exercise the renderer trust boundary. */ +function runtimeSong(value: unknown): RehearsalSong { + return value as RehearsalSong; +} + +describe("FirstHandoffCallout malformed song root", () => { + it("renders unavailable guidance instead of crashing when the runtime song root is null", () => { + expect(() => render()).not.toThrow(); + expect(screen.getByText("No handoff yet. Stay on tonight's map until a pass is marked.")).toBeTruthy(); + }); +}); From 5a1ae88878e3bbfada1ffe5efa1b1774a4ea96cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:38:13 -0700 Subject: [PATCH 12/20] fix(workspace): contain malformed handoff callout roots --- apps/desktop/src/features/workspace/FirstHandoffCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx index c2ea9e812..65be16bd2 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx @@ -55,7 +55,7 @@ export function FirstHandoffCallout({ useEffect(() => { setHeardHandoff(null); - }, [song.id, handoffSectionIndex, handoff?.section.id, handoff?.holdingRole?.id, handoff?.atSeconds]); + }, [song?.id, handoffSectionIndex, handoff?.section.id, handoff?.holdingRole?.id, handoff?.atSeconds]); if (!handoff) { return ( From 1e1bb460a6345bd290f59820fa8dd09756670a21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:06:53 -0700 Subject: [PATCH 13/20] test(workspace): reject mixed malformed handoff holder evidence --- ...eledHandoff.invalid-holder-elements.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts index 5650a2a6b..f8b060895 100644 --- a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts @@ -23,6 +23,15 @@ describe("resolveFirstLabeledHandoff runtime holder elements", () => { } }); + it("fails the holder closed when one malformed runtime role is mixed with otherwise valid roles", () => { + for (const malformedRole of [null, 42]) { + const { song, handoff } = songWithHandoff(); + handoff.roles = [...handoff.roles, malformedRole] as unknown as typeof handoff.roles; + + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + } + }); + it("keeps the pass band-wide when runtime partGraph contains a non-object element", () => { for (const malformedNode of [null, 42]) { const { song, handoff } = songWithHandoff(); @@ -32,4 +41,13 @@ describe("resolveFirstLabeledHandoff runtime holder elements", () => { expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); } }); + + it("fails the holder closed when one malformed runtime graph node is mixed with otherwise valid nodes", () => { + for (const malformedNode of [null, 42]) { + const { song, handoff } = songWithHandoff(); + handoff.partGraph = [...handoff.partGraph, malformedNode] as unknown as typeof handoff.partGraph; + + expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); + } + }); }); From 712ac3536df02297befdffe98605d14d32b4ea80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:08:25 -0700 Subject: [PATCH 14/20] test(workspace): preserve per-candidate malformed holder isolation --- ...eledHandoff.invalid-holder-elements.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts index f8b060895..5650a2a6b 100644 --- a/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts +++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-holder-elements.test.ts @@ -23,15 +23,6 @@ describe("resolveFirstLabeledHandoff runtime holder elements", () => { } }); - it("fails the holder closed when one malformed runtime role is mixed with otherwise valid roles", () => { - for (const malformedRole of [null, 42]) { - const { song, handoff } = songWithHandoff(); - handoff.roles = [...handoff.roles, malformedRole] as unknown as typeof handoff.roles; - - expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); - } - }); - it("keeps the pass band-wide when runtime partGraph contains a non-object element", () => { for (const malformedNode of [null, 42]) { const { song, handoff } = songWithHandoff(); @@ -41,13 +32,4 @@ describe("resolveFirstLabeledHandoff runtime holder elements", () => { expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); } }); - - it("fails the holder closed when one malformed runtime graph node is mixed with otherwise valid nodes", () => { - for (const malformedNode of [null, 42]) { - const { song, handoff } = songWithHandoff(); - handoff.partGraph = [...handoff.partGraph, malformedNode] as unknown as typeof handoff.partGraph; - - expect(resolveFirstLabeledHandoff(song)?.holdingRole).toBeNull(); - } - }); }); From 516574b722a509afac0d702880927826b23ba9b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:08:48 -0700 Subject: [PATCH 15/20] test(player): reject object-valued runtime song title copy --- apps/desktop/src/features/player/index.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 79101d321..99d0a3c76 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -86,4 +86,12 @@ describe("PlayerFeature", () => { 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 = songWithHandoff(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); }); From 3a68b9a2915e6b96076da828eca264c27e346cad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:09:28 -0700 Subject: [PATCH 16/20] fix(player): contain malformed runtime song title copy --- apps/desktop/src/features/player/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 6a6ac9610..29e1d6962 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -59,6 +59,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP } const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; return (
@@ -74,7 +75,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP }} >
- {song.title} + {songTitle} {sections.length} {sections.length === 1 ? "section" : "sections"} From 0f19f4f5d95da4ad440b958773b95aba76ba622b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:09:01 -0700 Subject: [PATCH 17/20] test(workspace): lock particle-safe Korean handoff copy --- .../features/workspace/FirstHandoffCallout.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx index 71ecd18e6..bab63882f 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx @@ -156,7 +156,18 @@ describe("FirstHandoffCallout", () => { render(); - expect(screen.getByText("리드 보컬이 0:22 핸드오프에서 넘깁니다.")).toBeTruthy(); + expect(screen.getByText("0:22 핸드오프에서 리드 보컬 파트가 넘깁니다.")).toBeTruthy(); expect(screen.queryByText(/handoff에서/)).toBeNull(); }); + + it("keeps dynamic Korean role names particle-safe without guessing Hangul morphology", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithHandoff(); + song.sections[1]!.roles[0]!.name = "피아노"; + + render(); + + expect(screen.getByText("0:22 핸드오프에서 피아노 파트가 넘깁니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:22 핸드오프에서 넘깁니다.")).toBeNull(); + }); }); From c3685178c6f393902748d25c30d087af8c6597b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:09:38 -0700 Subject: [PATCH 18/20] fix(workspace): keep Korean handoff role copy particle-safe --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 12535e07f..888b15771 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,7 +154,7 @@ "firstHandoffActionBand": "{at} 첫 핸드오프 듣기", "firstHandoffOpenAction": "{at} {role} 핸드오프 위치 열기", "firstHandoffOpenActionBand": "{at} 첫 핸드오프 위치 열기", - "firstHandoffBody": "{role}이 {at} {section}에서 넘깁니다.", + "firstHandoffBody": "{at} {section}에서 {role} 파트가 넘깁니다.", "firstHandoffBodyBand": "밴드가 {at} {section}에서 넘깁니다.", "firstHandoffArmed": "{at}에서 {role} 패스를 받으세요. 다음 파트를 잡으세요.", "firstHandoffArmedBand": "{at}에서 패스를 받으세요. 다음 파트를 잡으세요.", From 50cae0c83f9f1e613df815e2304d6ca10fd4aa54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:47:10 -0700 Subject: [PATCH 19/20] test(workspace): scope handoff navigation to owning workspace --- .../workspace/FirstHandoffCallout.test.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx index bab63882f..5760cf252 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx @@ -30,7 +30,7 @@ function songWithHandoff() { return song; } -function appendSongStructureTarget() { +function appendSongStructureTarget(parent: HTMLElement = document.body) { const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); @@ -42,7 +42,7 @@ function appendSongStructureTarget() { }); grid.appendChild(first); grid.appendChild(target); - document.body.appendChild(grid); + parent.appendChild(grid); return { grid, scrollIntoView }; } @@ -67,6 +67,28 @@ describe("FirstHandoffCallout", () => { grid.remove(); }); + it("scopes map navigation to the callout's owning workspace when two workspaces are mounted", () => { + const firstScope = document.createElement("div"); + const secondScope = document.createElement("div"); + document.body.append(firstScope, secondScope); + + render(, { container: firstScope }); + const firstTarget = appendSongStructureTarget(firstScope); + render(, { container: secondScope }); + const secondTarget = appendSongStructureTarget(secondScope); + + const actions = screen.getAllByRole("button", { + name: "Open Lead Vocal handoff at 0:22" + }); + fireEvent.click(actions[1]!); + + expect(firstTarget.scrollIntoView).not.toHaveBeenCalled(); + expect(secondTarget.scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + firstScope.remove(); + secondScope.remove(); + }); + it("does not claim map navigation completed when the rendered section target is missing", () => { render(); From 2643c0c7e9a741ca71ac15f082d398b7f0ce938a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:47:49 -0700 Subject: [PATCH 20/20] fix(workspace): scope handoff navigation to owning workspace --- .../workspace/FirstHandoffCallout.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx index 65be16bd2..1e60c7a37 100644 --- a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx @@ -41,6 +41,23 @@ function preferredHandoffScrollBehavior(): ScrollBehavior { : "smooth"; } +/** Resolve only the song-structure grid owned by this callout's workspace, with an unambiguous single-grid fallback. */ +function resolveHandoffGrid(action: HTMLElement): Element | null { + const localScope = action.closest("aside")?.parentElement; + if (localScope) { + const localGrids = localScope.querySelectorAll('[data-testid="song-structure-grid"]'); + if (localGrids.length === 1) { + return localGrids.item(0); + } + if (localGrids.length > 1) { + return null; + } + } + + const globalGrids = document.querySelectorAll('[data-testid="song-structure-grid"]'); + return globalGrids.length === 1 ? globalGrids.item(0) : null; +} + /** Name tonight's first labeled handoff and offer only an action that the current surface can execute. */ export function FirstHandoffCallout({ song, @@ -121,13 +138,13 @@ export function FirstHandoffCallout({