- {song.title}
+ {songTitle}
- {song.sections.length} {song.sections.length === 1 ? "section" : "sections"}
+ {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section) => (
+ {sections.map((section, sectionIndex) => (
{section.label}
diff --git a/apps/desktop/src/features/workspace/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();
+ });
+});
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..5760cf252
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.test.tsx
@@ -0,0 +1,195 @@
+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;
+}
+
+function appendSongStructureTarget(parent: HTMLElement = document.body) {
+ 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);
+ parent.appendChild(grid);
+ return { grid, scrollIntoView };
+}
+
+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();
+
+ 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("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( );
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx
new file mode 100644
index 000000000..1e60c7a37
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstHandoffCallout.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from "react";
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { Button } from "@/components/ui/button";
+import {
+ createTranslator,
+ detectPreferredLocale,
+ translateSectionFormLabel
+} from "../../i18n";
+import { 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";
+}
+
+/** 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,
+ actionMode = "workspace-scroll",
+ onHearHandoff
+}: FirstHandoffCalloutProps) {
+ 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);
+
+ 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: translateSectionFormLabel(locale, 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 (
+
+ {t("firstHandoffLabel")}
+ {heard ? armed : body}
+ {canExecuteAction ? (
+ {
+ if (actionMode === "callback-only") {
+ onHearHandoff!(handoff.atSeconds);
+ markHandoffActionComplete();
+ return;
+ }
+ const grid = resolveHandoffGrid(event.currentTarget);
+ const target = handoffSectionIndex >= 0 ? grid?.children.item(handoffSectionIndex) : null;
+ if (typeof target?.scrollIntoView !== "function") {
+ return;
+ }
+ target.scrollIntoView({
+ block: "nearest",
+ behavior: preferredHandoffScrollBehavior()
+ });
+ markHandoffActionComplete();
+ }}
+ >
+ {actionLabel}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..b9ceebdc3 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -326,4 +326,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 d44e20777..cbeed1d0a 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 { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -353,6 +354,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..4ef261956
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.invalid-section-collection.test.ts
@@ -0,0 +1,44 @@
+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;
+}
+
+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);
+
+ 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.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();
+ });
+});
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..6f34d7fd3
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstLabeledHandoff.ts
@@ -0,0 +1,181 @@
+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 whether every numeric index is present in a bounded runtime array. */
+function isDenseRuntimeArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ const length = Number(value.length);
+ if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
+ return false;
+ }
+ for (let index = 0; index < length; index += 1) {
+ if (!(index in value)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */
+function hasRankedPriority(role: RehearsalRole): boolean {
+ return (
+ typeof role.id === "string" &&
+ role.id.trim().length > 0 &&
+ typeof role.name === "string" &&
+ role.name.trim().length > 0 &&
+ Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority)
+ );
+}
+
+/** Return whether a section has a bounded, positive-length integer rehearsal window. */
+function hasBoundedTimeRange(section: RehearsalSection): boolean {
+ const timeRange = section.timeRange as Partial | null;
+ if (timeRange === null || typeof timeRange !== "object") {
+ return false;
+ }
+
+ const start = timeRange.start ?? -1;
+ const end = timeRange.end ?? -1;
+ return (
+ Number.isInteger(start) &&
+ start >= 0 &&
+ start <= MAX_SECTION_TIME_SECONDS &&
+ Number.isInteger(end) &&
+ end > start &&
+ end <= MAX_SECTION_TIME_SECONDS
+ );
+}
+
+/** Return safe identities that appear more than once in one section-local collection. */
+function repeatedIds(ids: string[]): Set {
+ const seen = new Set();
+ const repeated = new Set();
+ for (const id of ids) {
+ if (seen.has(id)) {
+ repeated.add(id);
+ } else {
+ seen.add(id);
+ }
+ }
+ return repeated;
+}
+
+/** Prefer the highest-priority ranked role, then a 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 (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) {
+ return [];
+ }
+
+ const safeRoleIds = section.roles
+ .filter(
+ (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0
+ )
+ .map((role) => role.id);
+ const safeGraphRoleIds = section.partGraph
+ .filter(
+ (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0
+ )
+ .map((node) => node.role_id);
+ const repeatedRoleIds = repeatedIds(safeRoleIds);
+ const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds);
+ const activeIds = new Set(
+ section.partGraph
+ .filter(
+ (node) =>
+ isRuntimeObject(node) &&
+ node.is_active === true &&
+ typeof node.role_id === "string" &&
+ node.role_id.trim().length > 0 &&
+ !repeatedGraphRoleIds.has(node.role_id)
+ )
+ .map((node) => node.role_id)
+ );
+
+ return section.roles.filter(
+ (role) =>
+ isRuntimeObject(role) &&
+ hasRankedPriority(role) &&
+ !repeatedRoleIds.has(role.id) &&
+ activeIds.has(role.id)
+ );
+}
+
+/** Return the first labeled handoff, or null when no safe pass remains. */
+export function resolveFirstLabeledHandoff(song: RehearsalSong): FirstLabeledHandoff | null {
+ if (!isRuntimeObject(song) || !isDenseRuntimeArray(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/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")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..90ef83762 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -149,6 +149,17 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease 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.",
"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..1fcefa811 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,17 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstHandoffLabel": "오늘 첫 핸드오프",
+ "firstHandoffAction": "{at}에 {role} 패스 듣기",
+ "firstHandoffActionBand": "{at} 첫 핸드오프 듣기",
+ "firstHandoffOpenAction": "{at} {role} 핸드오프 위치 열기",
+ "firstHandoffOpenActionBand": "{at} 첫 핸드오프 위치 열기",
+ "firstHandoffBody": "{at} {section}에서 {role} 파트가 넘깁니다.",
+ "firstHandoffBodyBand": "밴드가 {at} {section}에서 넘깁니다.",
+ "firstHandoffArmed": "{at}에서 {role} 패스를 받으세요. 다음 파트를 잡으세요.",
+ "firstHandoffArmedBand": "{at}에서 패스를 받으세요. 다음 파트를 잡으세요.",
+ "firstHandoffUnavailable": "아직 핸드오프가 없습니다. 패스가 표시될 때까지 오늘 지도에 머무르세요.",
+ "firstHandoffNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 핸드오프를 들으세요.",
"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..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`.