@@ -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/FirstStopCallout.localization.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.localization.test.tsx
new file mode 100644
index 000000000..754ac6bb6
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstStopCallout.localization.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstStopCallout } from "./FirstStopCallout";
+
+function songWithLocalizedStop() {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ const role = {
+ ...section.roles[0]!,
+ id: "keyboard-stop",
+ name: "피아노",
+ rehearsalPriority: "high" as const
+ };
+ section.id = "localized-stop";
+ section.label = "stop";
+ section.timeRange = { start: 10, end: 12 };
+ section.roles = [role];
+ section.partGraph = [
+ {
+ role_id: role.id,
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ return song;
+}
+
+describe("FirstStopCallout runtime and locale boundary", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("contains a malformed runtime song root instead of crashing the callout", () => {
+ render( );
+
+ expect(
+ screen.getByText("No stop yet. Stay on tonight's map until a cut is marked.")
+ ).toBeTruthy();
+ });
+
+ it("uses particle-safe Korean section-form copy instead of exposing the raw stop enum", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+
+ render( );
+
+ expect(screen.getByText("0:10 스톱에서 피아노 파트가 컷합니다.")).toBeTruthy();
+ expect(screen.queryByText("피아노이 0:10 스톱에서 컷합니다.")).toBeNull();
+ expect(screen.queryByText(/ stop에서 /)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx
new file mode 100644
index 000000000..a834ab939
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstStopCallout.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 { FirstStopCallout } from "./FirstStopCallout";
+
+function songWithStop() {
+ 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 };
+ stop.roles = [
+ {
+ ...verse.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high"
+ }
+ ];
+ stop.partGraph = [
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse, stop];
+ return song;
+}
+
+describe("FirstStopCallout 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 stop at 0:18" }));
+
+ expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });
+ grid.remove();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx
new file mode 100644
index 000000000..a8be247fe
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstStopCallout.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 { FirstStopCallout } from "./FirstStopCallout";
+
+function songWithStop() {
+ 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 };
+ stop.roles = [
+ {
+ ...verse.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high"
+ }
+ ];
+ stop.partGraph = [
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse, stop];
+ 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("FirstStopCallout", () => {
+ it("names the first stop 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 stop at 0:18"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).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 stop at 0:18" }));
+
+ expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy();
+ expect(screen.queryByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeNull();
+ });
+
+ it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => {
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+ const onHearStop = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" }));
+ expect(onHearStop).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 = songWithStop();
+ song.sections[1]!.id = "analysis section / duplicate";
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" }));
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+
+ grid.remove();
+ });
+
+ it("shows fresh guidance when the first stop changes or returns later", () => {
+ const initialSong = songWithStop();
+ const { grid } = appendSongStructureTarget();
+ const { rerender } = render( );
+ fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" }));
+ expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy();
+
+ const nextSong = songWithStop();
+ nextSong.id = "next-song";
+ nextSong.sections[1]!.timeRange = { start: 24, end: 25 };
+ rerender( );
+ expect(screen.getByText("Lead Vocal cuts the stop at 0:24.")).toBeTruthy();
+
+ grid.remove();
+ });
+
+ it("keeps an unavailable stop guidance-only", () => {
+ render( );
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(
+ screen.getByText("No stop yet. Stay on tonight's map until a cut is marked.")
+ ).toBeTruthy();
+ });
+
+ it("names a band-wide cut when no part holds the stop", () => {
+ const song = songWithStop();
+ song.sections[1]!.partGraph[0]!.is_active = false;
+ render( );
+ expect(screen.getByRole("button", { name: "Open the first stop at 0:18" })).toBeTruthy();
+ expect(screen.getByText("The band cuts the stop at 0:18.")).toBeTruthy();
+ });
+
+ it("renders Hear only in callback-only mode when a seek callback exists", () => {
+ const onHearStop = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal cut at 0:18" }));
+ expect(onHearStop).toHaveBeenCalledWith(18);
+ });
+
+ it("hides the Hear action in callback-only mode without a seek callback", () => {
+ render( );
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.tsx
new file mode 100644
index 000000000..a3aab4fcd
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstStopCallout.tsx
@@ -0,0 +1,152 @@
+import { useEffect, useState } from "react";
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { Button } from "@/components/ui/button";
+import {
+ createTranslator,
+ detectPreferredLocale,
+ translateSectionFormLabel
+} from "../../i18n";
+import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff";
+
+/** Props for the first-stop rehearsal callout. */
+export interface FirstStopCalloutProps {
+ song: RehearsalSong;
+ actionMode?: "workspace-scroll" | "callback-only";
+ onHearStop?: (atSeconds: number) => void;
+}
+
+type StopCopyValues = Readonly>;
+
+type HeardStop = Readonly<{
+ songId: string;
+ sectionId: string;
+ sectionIndex: number;
+ holdingRoleId: string | null;
+ atSeconds: number;
+}>;
+
+/** Interpolate stop placeholders once so rehearsal data is never rescanned as template syntax. */
+function formatStopCopy(template: string, values: StopCopyValues): string {
+ return template.replace(/\{(role|section|at)\}/g, (placeholder) => {
+ const key = placeholder.slice(1, -1) as keyof StopCopyValues;
+ return values[key] ?? placeholder;
+ });
+}
+
+/** Use immediate scrolling when the operating system requests reduced motion. */
+function preferredStopScrollBehavior(): ScrollBehavior {
+ return typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "auto"
+ : "smooth";
+}
+
+/** Name tonight's first stop and offer only an action that the current surface can execute. */
+export function FirstStopCallout({
+ song,
+ actionMode = "workspace-scroll",
+ onHearStop
+}: FirstStopCalloutProps) {
+ const locale = detectPreferredLocale();
+ const t = createTranslator(locale);
+ const runtimeSong = song as unknown as Partial | null;
+ const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : "";
+ const stop = resolveFirstStopHandoff(song);
+ const stopSectionIndex =
+ stop && Array.isArray(runtimeSong?.sections)
+ ? runtimeSong.sections.indexOf(stop.section)
+ : -1;
+ const [heardStop, setHeardStop] = useState(null);
+
+ useEffect(() => {
+ setHeardStop(null);
+ }, [songId, stopSectionIndex, stop?.section.id, stop?.holdingRole?.id, stop?.atSeconds]);
+
+ if (!stop) {
+ return (
+
+ );
+ }
+
+ const heard =
+ heardStop?.songId === songId &&
+ heardStop.sectionId === stop.section.id &&
+ heardStop.sectionIndex === stopSectionIndex &&
+ heardStop.holdingRoleId === (stop.holdingRole?.id ?? null) &&
+ heardStop.atSeconds === stop.atSeconds;
+ const at = formatStopTime(stop.atSeconds);
+ const copyValues: StopCopyValues = {
+ role: stop.holdingRole?.name ?? "",
+ section: translateSectionFormLabel(locale, stop.section.label),
+ at
+ };
+ const hasRole = stop.holdingRole !== null;
+ const actionLabel = formatStopCopy(
+ t(
+ actionMode === "callback-only"
+ ? hasRole
+ ? "firstStopAction"
+ : "firstStopActionBand"
+ : hasRole
+ ? "firstStopOpenAction"
+ : "firstStopOpenActionBand"
+ ),
+ copyValues
+ );
+ const body = formatStopCopy(t(hasRole ? "firstStopBody" : "firstStopBodyBand"), copyValues);
+ const armed = formatStopCopy(t(hasRole ? "firstStopArmed" : "firstStopArmedBand"), copyValues);
+ const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearStop === "function";
+ /** Record completion only after the owning surface has executed the selected stop action. */
+ const markStopActionComplete = () => {
+ setHeardStop({
+ songId,
+ sectionId: stop.section.id,
+ sectionIndex: stopSectionIndex,
+ holdingRoleId: stop.holdingRole?.id ?? null,
+ atSeconds: stop.atSeconds
+ });
+ };
+
+ return (
+
+ {t("firstStopLabel")}
+ {heard ? armed : body}
+ {canExecuteAction ? (
+ {
+ if (actionMode === "callback-only") {
+ onHearStop!(stop.atSeconds);
+ markStopActionComplete();
+ return;
+ }
+ const grid = document.querySelector('[data-testid="song-structure-grid"]');
+ const target = stopSectionIndex >= 0 ? grid?.children.item(stopSectionIndex) : null;
+ if (typeof target?.scrollIntoView !== "function") {
+ return;
+ }
+ target.scrollIntoView({
+ block: "nearest",
+ behavior: preferredStopScrollBehavior()
+ });
+ markStopActionComplete();
+ }}
+ >
+ {actionLabel}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..93c3142eb 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 stop as workspace navigation", () => {
+ 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 };
+ stop.roles = [
+ {
+ ...verse.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high"
+ }
+ ];
+ stop.partGraph = [
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse, stop];
+
+ 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 stop at 0:18"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..725f5616a 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 { FirstStopCallout } from "./FirstStopCallout";
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/firstStopHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts
new file mode 100644
index 000000000..d8f702ce4
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+const runtimeStringFalse = "false" as unknown as boolean;
+
+describe("resolveFirstStopHandoff activity-type authority", () => {
+ it("does not treat a string false flag as an active stop holder", () => {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "stop-1";
+ section.label = "stop";
+ section.timeRange = { start: 18, end: 19 };
+ 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(resolveFirstStopHandoff(song)?.holdingRole?.id).toBe("active-bass");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts
new file mode 100644
index 000000000..dd1b37a7a
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function songWithStop() {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const stop = structuredClone(verse);
+ const role = {
+ ...verse.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ rehearsalPriority: "high" as const
+ };
+
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ stop.roles = [role];
+ stop.partGraph = [
+ {
+ role_id: role.id,
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse, stop];
+ return { song, stop, role };
+}
+
+describe("resolveFirstStopHandoff ambiguous identities", () => {
+ it("keeps a band-wide cut when a stop repeats one role identity", () => {
+ const { song, stop, role } = songWithStop();
+ stop.roles = [role, { ...role, name: "Duplicate Bass" }];
+
+ const result = resolveFirstStopHandoff(song);
+
+ expect(result?.section).toBe(stop);
+ expect(result?.holdingRole).toBeNull();
+ });
+
+ it("keeps a band-wide cut when a stop repeats one graph-node identity", () => {
+ const { song, stop, role } = songWithStop();
+ stop.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 = resolveFirstStopHandoff(song);
+
+ expect(result?.section).toBe(stop);
+ expect(result?.holdingRole).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts
new file mode 100644
index 000000000..127cc4ca2
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+describe("resolveFirstStopHandoff inactive labeled holder", () => {
+ it("does not name an inactive labeled role as the stop holder", () => {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "stop-1";
+ section.label = "stop";
+ section.timeRange = { start: 18, end: 19 };
+ 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 stop = resolveFirstStopHandoff(song);
+ expect(stop?.section.id).toBe("stop-1");
+ expect(stop?.holdingRole).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts
new file mode 100644
index 000000000..29f5e3f39
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function songWithStop() {
+ const song = createDemoRehearsalSong();
+ const stop = structuredClone(song.sections[0]!);
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ song.sections = [stop];
+ return { song, stop };
+}
+
+describe("resolveFirstStopHandoff runtime holder collections", () => {
+ it("keeps the cut band-wide when runtime roles are not an array", () => {
+ const { song, stop } = songWithStop();
+ stop.roles = null as unknown as typeof stop.roles;
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull();
+ });
+
+ it("keeps the cut band-wide when runtime partGraph is not an array", () => {
+ const { song, stop } = songWithStop();
+ stop.partGraph = null as unknown as typeof stop.partGraph;
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts
new file mode 100644
index 000000000..be41e8be4
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function songWithStop() {
+ const song = createDemoRehearsalSong();
+ const stop = structuredClone(song.sections[0]!);
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ song.sections = [stop];
+ return { song, stop };
+}
+
+describe("resolveFirstStopHandoff runtime holder elements", () => {
+ it("keeps the cut band-wide when runtime roles contain a non-object element", () => {
+ for (const malformedRole of [null, 42]) {
+ const { song, stop } = songWithStop();
+ stop.roles = [malformedRole] as unknown as typeof stop.roles;
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull();
+ }
+ });
+
+ it("keeps the cut band-wide when runtime partGraph contains a non-object element", () => {
+ for (const malformedNode of [null, 42]) {
+ const { song, stop } = songWithStop();
+ stop.partGraph = [malformedNode] as unknown as typeof stop.partGraph;
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull();
+ }
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts
new file mode 100644
index 000000000..ef2dc5cc4
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+describe("resolveFirstStopHandoff runtime role identity", () => {
+ it("ignores an active stop role whose runtime id is not a non-empty string", () => {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "stop-1";
+ section.label = "stop";
+ section.timeRange = { start: 18, end: 19 };
+
+ 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(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(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 = "stop-1";
+ section.label = "stop";
+ section.timeRange = { start: 18, end: 19 };
+ 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(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts
new file mode 100644
index 000000000..0e4ee8860
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.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 { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function songWithRuntimeSections(sections: unknown): RehearsalSong {
+ const song = createDemoRehearsalSong();
+ song.sections = sections as RehearsalSong["sections"];
+ return song;
+}
+
+describe("resolveFirstStopHandoff runtime section collection", () => {
+ it("fails closed when the runtime section collection is not an array", () => {
+ const song = songWithRuntimeSections(null);
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ });
+
+ it("ignores malformed section elements instead of dereferencing them", () => {
+ const song = songWithRuntimeSections([null, 42]);
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts
new file mode 100644
index 000000000..d4b700183
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function malformedStop(sectionId: unknown) {
+ const song = createDemoRehearsalSong();
+ const stop = structuredClone(song.sections[0]!);
+ stop.id = sectionId as string;
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ song.sections = [stop];
+ return song;
+}
+
+describe("resolveFirstStopHandoff runtime section identity", () => {
+ it("rejects stop sections whose runtime id is not a non-empty string", () => {
+ for (const invalidId of [42, " "]) {
+ const song = malformedStop(invalidId);
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ }
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts
new file mode 100644
index 000000000..db86f9857
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.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 { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+describe("resolveFirstStopHandoff runtime time range", () => {
+ it("rejects a stop whose runtime timeRange is not an object", () => {
+ const song = createDemoRehearsalSong();
+ const stop = structuredClone(song.sections[0]!);
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = null as unknown as typeof stop.timeRange;
+ song.sections = [stop];
+
+ expect(() => resolveFirstStopHandoff(song)).not.toThrow();
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ });
+
+ it("skips a zero-length stop window and selects the next valid cut", () => {
+ const song = createDemoRehearsalSong();
+ const zeroLengthStop = structuredClone(song.sections[0]!);
+ zeroLengthStop.id = "stop-zero-length";
+ zeroLengthStop.label = "stop";
+ zeroLengthStop.timeRange = { start: 10, end: 10 };
+
+ const validStop = structuredClone(song.sections[0]!);
+ validStop.id = "stop-valid";
+ validStop.label = "stop";
+ validStop.timeRange = { start: 18, end: 19 };
+ song.sections = [zeroLengthStop, validStop];
+
+ expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid");
+ });
+
+ it("skips a stop whose runtime window exceeds the shared u32 timing contract", () => {
+ const song = createDemoRehearsalSong();
+ const overflowingStop = structuredClone(song.sections[0]!);
+ overflowingStop.id = "stop-overflow";
+ overflowingStop.label = "stop";
+ overflowingStop.timeRange = { start: 10, end: MAX_SECTION_TIME_SECONDS + 1 };
+
+ const validStop = structuredClone(song.sections[0]!);
+ validStop.id = "stop-valid";
+ validStop.label = "stop";
+ validStop.timeRange = { start: 18, end: 19 };
+ song.sections = [overflowingStop, validStop];
+
+ expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid");
+ });
+
+ it("skips a stop whose runtime window uses fractional seconds outside the shared timing contract", () => {
+ const song = createDemoRehearsalSong();
+ const fractionalStop = structuredClone(song.sections[0]!);
+ fractionalStop.id = "stop-fractional";
+ fractionalStop.label = "stop";
+ fractionalStop.timeRange = { start: 10.5, end: 11.5 };
+
+ const validStop = structuredClone(song.sections[0]!);
+ validStop.id = "stop-valid";
+ validStop.label = "stop";
+ validStop.timeRange = { start: 18, end: 19 };
+ song.sections = [fractionalStop, validStop];
+
+ expect(resolveFirstStopHandoff(song)?.section.id).toBe("stop-valid");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts
new file mode 100644
index 000000000..d09129610
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function withStopSection(
+ 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 stop = structuredClone(verse);
+ stop.id = overrides.id ?? "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: overrides.start ?? 18, end: overrides.end ?? 19 };
+ const roleId = overrides.roleId ?? "lead-vocal";
+ stop.roles = [
+ {
+ ...verse.roles[2]!,
+ id: roleId,
+ name: overrides.roleName ?? "Lead Vocal",
+ rehearsalPriority: overrides.priority ?? "high"
+ }
+ ];
+ stop.partGraph = [
+ {
+ role_id: roleId,
+ is_active: overrides.isActive ?? true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [verse, stop];
+ return song;
+}
+
+describe("resolveFirstStopHandoff", () => {
+ it("returns null when the demo song has no labeled stop", () => {
+ expect(resolveFirstStopHandoff(createDemoRehearsalSong())).toBeNull();
+ expect(formatStopTime(Number.NaN)).toBe("0:00");
+ expect(formatStopTime(-4)).toBe("0:00");
+ });
+
+ it("picks the earliest labeled stop and the part that holds the cut", () => {
+ const song = withStopSection({ start: 18, end: 19 });
+ const stop = resolveFirstStopHandoff(song);
+
+ expect(stop?.section.id).toBe("stop-1");
+ expect(stop?.holdingRole?.id).toBe("lead-vocal");
+ expect(stop?.atSeconds).toBe(18);
+ expect(formatStopTime(stop?.atSeconds ?? -1)).toBe("0:18");
+ });
+
+ it("prefers the earlier of two labeled stops", () => {
+ const song = withStopSection({ id: "stop-late", start: 40, end: 41 });
+ const verse = song.sections[0]!;
+ const earlier = structuredClone(song.sections[1]!);
+ earlier.id = "stop-early";
+ earlier.timeRange = { start: 12, end: 13 };
+ 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 stop = resolveFirstStopHandoff(song);
+ expect(stop?.section.id).toBe("stop-early");
+ expect(stop?.holdingRole?.id).toBe("bass-guitar");
+ expect(stop?.atSeconds).toBe(12);
+ });
+
+ it("keeps a band-wide cut when no active ranked role holds it", () => {
+ const song = withStopSection({ isActive: false });
+ const stop = resolveFirstStopHandoff(song);
+ expect(stop?.section.id).toBe("stop-1");
+ expect(stop?.holdingRole).toBeNull();
+ expect(stop?.atSeconds).toBe(18);
+ });
+
+ it("skips a stop whose rehearsal window is unbounded", () => {
+ const song = withStopSection({ start: Number.NaN, end: 19 });
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ });
+
+ it("skips a stop whose end precedes its start", () => {
+ const song = withStopSection({ start: 20, end: 10 });
+ expect(resolveFirstStopHandoff(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts
new file mode 100644
index 000000000..b77ea3eaa
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.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 stop: the earliest labeled cut and the part that holds it. */
+export type FirstStopHandoff = {
+ section: RehearsalSection;
+ holdingRole: RehearsalRole | null;
+ atSeconds: number;
+};
+
+/** Format a non-negative stop time as m:ss for rehearsal copy. */
+export function formatStopTime(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 stop, or null when no safe cut remains. */
+export function resolveFirstStopHandoff(song: RehearsalSong): FirstStopHandoff | null {
+ if (!isRuntimeObject(song) || !Array.isArray(song.sections)) {
+ return null;
+ }
+
+ const stopSections = song.sections
+ .filter(
+ (section) =>
+ isRuntimeObject(section) &&
+ section.label === "stop" &&
+ 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 = stopSections[0];
+ if (!section) {
+ return null;
+ }
+
+ return {
+ section,
+ holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)),
+ atSeconds: section.timeRange.start
+ };
+}
\ No newline at end of file
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..ecd368e65 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-stop keys in both baseline locales", () => {
+ const tEn = createTranslator("en");
+ const tKo = createTranslator("ko");
+ expect(tEn("firstStopLabel")).toBe("Tonight's first stop");
+ expect(tKo("firstStopLabel")).toBe("오늘 첫 스톱");
+ expect(tEn("firstStopOpenAction")).toContain("{role}");
+ expect(tKo("firstStopOpenAction")).toContain("{role}");
+ });
});
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..aec0b6f9d 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,33 @@ const dictionaries = {
ko: koCommon
} as const;
+const sectionFormLabels: Readonly>>> = {
+ en: {
+ intro: "intro",
+ verse: "verse",
+ "pre-chorus": "pre-chorus",
+ chorus: "chorus",
+ bridge: "bridge",
+ outro: "outro",
+ tag: "tag",
+ pickup: "pickup",
+ stop: "stop",
+ handoff: "handoff"
+ },
+ ko: {
+ intro: "인트로",
+ verse: "벌스",
+ "pre-chorus": "프리코러스",
+ chorus: "코러스",
+ bridge: "브리지",
+ outro: "아웃트로",
+ tag: "태그",
+ pickup: "픽업",
+ stop: "스톱",
+ handoff: "핸드오프"
+ }
+};
+
/** Documented. */
export function createTranslator(locale: Locale = "en") {
return function t(key: TranslationKey): string {
@@ -18,6 +46,11 @@ export function createTranslator(locale: Locale = "en") {
};
}
+/** Return localized buyer copy for a validated section form label. */
+export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string {
+ return sectionFormLabels[locale][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..42bdab382 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",
+ "firstStopLabel": "Tonight's first stop",
+ "firstStopAction": "Hear {role} cut at {at}",
+ "firstStopActionBand": "Hear the first stop at {at}",
+ "firstStopOpenAction": "Open {role} stop at {at}",
+ "firstStopOpenActionBand": "Open the first stop at {at}",
+ "firstStopBody": "{role} cuts the {section} at {at}.",
+ "firstStopBodyBand": "The band cuts the {section} at {at}.",
+ "firstStopArmed": "Hold {role}'s cut at {at}. Do not play through it.",
+ "firstStopArmedBand": "Hold the cut at {at}. Do not play through it.",
+ "firstStopUnavailable": "No stop yet. Stay on tonight's map until a cut is marked.",
+ "firstStopNeedsSong": "Analyze tonight's song first, then hear the first stop 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..cbf79c4f0 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,17 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstStopLabel": "오늘 첫 스톱",
+ "firstStopAction": "{at}에 {role} 컷 듣기",
+ "firstStopActionBand": "{at} 첫 스톱 듣기",
+ "firstStopOpenAction": "{at} {role} 스톱 위치 열기",
+ "firstStopOpenActionBand": "{at} 첫 스톱 위치 열기",
+ "firstStopBody": "{at} {section}에서 {role} 파트가 컷합니다.",
+ "firstStopBodyBand": "밴드가 {at} {section}에서 컷합니다.",
+ "firstStopArmed": "{at}에서 {role} 컷을 지키세요. 그대로 밀고 가지 마세요.",
+ "firstStopArmedBand": "{at}에서 컷을 지키세요. 그대로 밀고 가지 마세요.",
+ "firstStopUnavailable": "아직 스톱이 없습니다. 컷이 표시될 때까지 오늘 지도에 머무르세요.",
+ "firstStopNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 스톱을 들으세요.",
"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..2874beb8e 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 Stop Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstStopCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled stop, and the time. `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 `onHearStop` exists and delegates the exact stop second to that callback. Keep the unavailable state guidance-only. |
| 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-stop-navigation.md b/docs/doctoring/reduced-motion-first-stop-navigation.md
new file mode 100644
index 000000000..6490c2c38
--- /dev/null
+++ b/docs/doctoring/reduced-motion-first-stop-navigation.md
@@ -0,0 +1,14 @@
+# Reduced-motion first-stop navigation
+
+Workspace map navigation for tonight's first stop follows the operating-system reduced-motion preference.
+
+When `prefers-reduced-motion: reduce` matches, `FirstStopCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`.
+
+This is a presentation contract only. Stop resolution, action-mode authority, and analysis-id isolation stay unchanged.
+
+## 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`.