(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,
+ previousSection: stop.previousSectionLabel
+ ? translateSectionFormLabel(locale, stop.previousSectionLabel)
+ : "",
+ nextSection: stop.nextSectionLabel
+ ? translateSectionFormLabel(locale, stop.nextSectionLabel)
+ : ""
+ };
+ 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 route = stop.hasFollowingSection
+ ? stop.previousSectionLabel && stop.nextSectionLabel
+ ? formatStopCopy(t("firstStopRouteBoth"), copyValues)
+ : stop.nextSectionLabel
+ ? formatStopCopy(t("firstStopRouteNext"), copyValues)
+ : stop.previousSectionLabel
+ ? formatStopCopy(t("firstStopRoutePrevious"), copyValues)
+ : null
+ : null;
+ 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 (
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
index 834d1e8f0..24385b419 100644
--- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx
+++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx
@@ -1,8 +1,9 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { useId, useMemo } from "react";
-import { createTranslator, detectPreferredLocale } from "../../i18n";
+import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { fillRangeCopy, playableRange } from "./firstRangeSqueeze";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -19,6 +20,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
+ const tonightStop = useMemo(() => resolveFirstStopHandoff(song), [song]);
/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
@@ -120,6 +122,19 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{t("sectionGrooveLabel")}
{section.groove}
+ {tonightStop?.section === section && tonightStop.hasFollowingSection ? (
+
+ {tonightStop.nextSectionLabel
+ ? t("sectionStopNextAction").replace(
+ "{nextSectionLabel}",
+ translateSectionFormLabel(locale, tonightStop.nextSectionLabel)
+ )
+ : t("sectionStopNextActionBare")}
+
+ ) : 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.route-context.test.tsx b/apps/desktop/src/features/workspace/firstStopHandoff.route-context.test.tsx
new file mode 100644
index 000000000..5ccee22f0
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.route-context.test.tsx
@@ -0,0 +1,106 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstStopCallout } from "./FirstStopCallout";
+import { SectionRoadmap } from "./SectionRoadmap";
+import { resolveFirstStopHandoff } from "./firstStopHandoff";
+
+function songWithRouteContext() {
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ const verse = structuredClone(seed);
+ const stop = structuredClone(seed);
+ const chorus = structuredClone(seed);
+
+ verse.id = "verse-1";
+ verse.label = "verse";
+ verse.timeRange = { start: 0, end: 10 };
+
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: 10, end: 11 };
+ stop.roles = stop.roles.map((role, index) => ({
+ ...role,
+ id: `stop-role-${index}`,
+ name: index === 0 ? "Bass" : role.name
+ }));
+ stop.partGraph = stop.roles.map((role) => ({
+ role_id: role.id,
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }));
+
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 11, end: 24 };
+ chorus.roles = chorus.roles.map((role, index) => ({ ...role, id: `chorus-role-${index}` }));
+
+ song.sections = [verse, stop, chorus];
+ return song;
+}
+
+function songWithTerminalStop() {
+ const terminalSong = songWithRouteContext();
+ terminalSong.sections = terminalSong.sections.slice(0, 2);
+ return terminalSong;
+}
+
+describe("first-stop route context succession", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps the previous and next named form around the validated stop", () => {
+ const result = resolveFirstStopHandoff(songWithRouteContext());
+
+ expect(result?.previousSectionLabel).toBe("verse");
+ expect(result?.nextSectionLabel).toBe("chorus");
+ expect(result?.hasFollowingSection).toBe(true);
+ });
+
+ it("distinguishes a terminal stop from an unnamed following section", () => {
+ const result = resolveFirstStopHandoff(songWithTerminalStop());
+
+ expect(result?.previousSectionLabel).toBe("verse");
+ expect(result?.nextSectionLabel).toBeNull();
+ expect(result?.hasFollowingSection).toBe(false);
+ });
+
+ it("names the re-entry in the existing actionable stop callout without adding a second static card", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.getByText("After verse, cut together here. Come back in on chorus.")).toBeTruthy();
+ expect(screen.getAllByLabelText("Tonight's first stop")).toHaveLength(1);
+ });
+
+ it("omits invented re-entry copy when the validated stop ends the song", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.queryByTestId("first-stop-route")).toBeNull();
+ });
+
+ it("puts the re-entry action only on the validated stop roadmap card", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.getByTestId("first-stop-action-stop-1")).toHaveTextContent(
+ "Cut together here, then come back in on chorus."
+ );
+ expect(screen.queryByTestId("first-stop-action-verse-1")).toBeNull();
+ expect(screen.queryByTestId("first-stop-action-chorus-1")).toBeNull();
+ });
+
+ it("omits a roadmap re-entry action when the validated stop is terminal", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.queryByTestId("first-stop-action-stop-1")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx b/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx
new file mode 100644
index 000000000..9645f0f20
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.terminal-stop.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstStopCallout } from "./FirstStopCallout";
+import { SectionRoadmap } from "./SectionRoadmap";
+
+function songWithTerminalStop() {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ const stop = structuredClone(song.sections[0]!);
+
+ verse.id = "verse-1";
+ verse.label = "verse";
+ verse.timeRange = { start: 0, end: 18 };
+
+ stop.id = "stop-final";
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ stop.roles = stop.roles.map((role, index) => ({
+ ...role,
+ id: `terminal-role-${index}`
+ }));
+ stop.partGraph = stop.roles.map((role) => ({
+ role_id: role.id,
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }));
+
+ song.sections = [verse, stop];
+ return song;
+}
+
+describe("terminal first-stop guidance", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("does not invent a re-entry after the final stop in the callout", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.getByText("After verse, cut together here. Hold the cut.")).toBeTruthy();
+ expect(screen.queryByText(/downbeat/i)).toBeNull();
+ });
+
+ it("keeps the terminal roadmap action free of a nonexistent re-entry", () => {
+ vi.stubGlobal("navigator", { language: "en-US" });
+
+ render();
+
+ expect(screen.getByTestId("first-stop-action-stop-final")).toHaveTextContent(
+ "Cut together here. Hold the cut."
+ );
+ expect(screen.queryByText(/come back in/i)).toBeNull();
+ });
+});
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..b4d0f68a1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts
@@ -0,0 +1,219 @@
+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;
+const SECTION_FORM_LABELS = new Set([
+ "intro",
+ "verse",
+ "pre-chorus",
+ "chorus",
+ "bridge",
+ "outro",
+ "tag",
+ "pickup",
+ "stop",
+ "handoff"
+]);
+
+/** Tonight's first stop: the earliest labeled cut, its holder, and adjacent form context. */
+export type FirstStopHandoff = {
+ section: RehearsalSection;
+ holdingRole: RehearsalRole | null;
+ atSeconds: number;
+ previousSectionLabel: RehearsalSection["label"] | null;
+ nextSectionLabel: RehearsalSection["label"] | null;
+ hasFollowingSection: boolean;
+};
+
+/** 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)
+ );
+}
+
+/** Validate section identities once so a duplicate cannot redirect a map action. */
+function uniqueRuntimeSections(song: RehearsalSong): RehearsalSection[] | null {
+ const sections: RehearsalSection[] = [];
+ const seenSectionIds = new Set();
+ for (const sectionValue of song.sections as unknown[]) {
+ if (!isRuntimeObject(sectionValue)) {
+ return null;
+ }
+ const section = sectionValue as RehearsalSection;
+ if (typeof section.id !== "string" || section.id.trim().length === 0) {
+ return null;
+ }
+ const sectionId = section.id.trim();
+ if (seenSectionIds.has(sectionId)) {
+ return null;
+ }
+ seenSectionIds.add(sectionId);
+ sections.push(section);
+ }
+ return sections;
+}
+
+/** Return a supported buyer-safe form label or null rather than echoing malformed runtime data. */
+function safeSectionLabel(section: RehearsalSection | undefined): RehearsalSection["label"] | null {
+ if (!section) {
+ return null;
+ }
+ const label: unknown = section.label;
+ if (typeof label !== "string" || !SECTION_FORM_LABELS.has(label as RehearsalSection["label"])) {
+ return null;
+ }
+ return label as RehearsalSection["label"];
+}
+
+/** 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 uniqueSections = uniqueRuntimeSections(song);
+ if (!uniqueSections) {
+ return null;
+ }
+
+ const timelineSections = uniqueSections
+ .filter((section) => 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 stopIndex = timelineSections.findIndex((section) => section.label === "stop");
+ if (stopIndex < 0) {
+ return null;
+ }
+
+ const section = timelineSections[stopIndex];
+ if (!section) {
+ return null;
+ }
+
+ const followingSection = timelineSections[stopIndex + 1];
+ return {
+ section,
+ holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)),
+ atSeconds: section.timeRange.start,
+ previousSectionLabel: safeSectionLabel(timelineSections[stopIndex - 1]),
+ nextSectionLabel: safeSectionLabel(followingSection),
+ hasFollowingSection: followingSection !== undefined
+ };
+}
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..bc91440cd 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -149,10 +149,26 @@
"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}.",
+ "firstStopRouteBoth": "After {previousSection}, cut together here. Come back in on {nextSection}.",
+ "firstStopRouteNext": "Cut together here. Come back in on {nextSection}.",
+ "firstStopRoutePrevious": "After {previousSection}, cut together here. Hold the cut.",
+ "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}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
"sectionRangeLabel": "Range",
- "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
+ "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.",
+ "sectionStopNextAction": "Cut together here, then come back in on {nextSectionLabel}.",
+ "sectionStopNextActionBare": "Cut together here. Hold the cut."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..c0f51d226 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,10 +149,26 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstStopLabel": "오늘 첫 스톱",
+ "firstStopAction": "{at}에 {role} 컷 듣기",
+ "firstStopActionBand": "{at} 첫 스톱 듣기",
+ "firstStopOpenAction": "{at} {role} 스톱 위치 열기",
+ "firstStopOpenActionBand": "{at} 첫 스톱 위치 열기",
+ "firstStopBody": "{at} {section}에서 {role} 파트가 컷합니다.",
+ "firstStopBodyBand": "밴드가 {at} {section}에서 컷합니다.",
+ "firstStopRouteBoth": "{previousSection} 다음에 여기서 함께 끊고, {nextSection}에서 다시 들어옵니다.",
+ "firstStopRouteNext": "여기서 함께 끊고, {nextSection}에서 다시 들어옵니다.",
+ "firstStopRoutePrevious": "{previousSection} 다음에 여기서 함께 끊고, 컷을 유지합니다.",
+ "firstStopArmed": "{at}에서 {role} 컷을 지키세요. 그대로 밀고 가지 마세요.",
+ "firstStopArmedBand": "{at}에서 컷을 지키세요. 그대로 밀고 가지 마세요.",
+ "firstStopUnavailable": "아직 스톱이 없습니다. 컷이 표시될 때까지 오늘 지도에 머무르세요.",
+ "firstStopNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 스톱을 들으세요.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
"sectionRangeLabel": "음역",
- "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
+ "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.",
+ "sectionStopNextAction": "여기서 함께 끊고, {nextSectionLabel}에서 다시 들어옵니다.",
+ "sectionStopNextActionBare": "여기서 함께 끊고, 컷을 유지합니다."
}
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 3cf5261b9..141c1257c 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -9,7 +9,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c
## Core rehearsal artifacts
- likely harmony by section and by role
-- section roadmap with entries, dropouts, pickups, stops, and handoffs
+- section roadmap with entries, dropouts, pickups, stops, and handoffs; the ready workspace names tonight's first stop and the next entrance when one actually exists
- groove and timing cues
- role ranges, overlap warnings, and simplification guidance
- transposition, capo, tuning, or setup cues where relevant
diff --git a/docs/architecture/rehearsal-domain-model.md b/docs/architecture/rehearsal-domain-model.md
index 4b177dbf1..29e7ba739 100644
--- a/docs/architecture/rehearsal-domain-model.md
+++ b/docs/architecture/rehearsal-domain-model.md
@@ -24,6 +24,7 @@ BandScope models a song as rehearsal-facing roles, not only as a single global h
- A section model should support intro, verse, pre-chorus, chorus, bridge, outro, tags, pickups, stops, and handoffs.
- A rehearsal roadmap should expose who enters, who drops out, and where the band must re-enter together.
+- The ready workspace names tonight's first validated stop so the room can cut together before the next entrance when a following section actually exists; terminal stops do not invent a re-entry.
- Cue anchors should support lyric phrases, count-based entries, or section-transition markers.
## Groove cues and rhythmic feel
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`.
diff --git a/services/analysis-engine/src/bandscope_analysis/__init__.py b/services/analysis-engine/src/bandscope_analysis/__init__.py
index 3867248e8..020a6b910 100644
--- a/services/analysis-engine/src/bandscope_analysis/__init__.py
+++ b/services/analysis-engine/src/bandscope_analysis/__init__.py
@@ -1,6 +1,14 @@
-"""BandScope analysis engine package."""
+"""BandScope analysis engine package and application composition root."""
-from .api import get_analysis_status
+from . import api as _api
from .health import build_health_report
+from .stop_projection import with_detected_stop_projection
+
+# Keep the public API module as the stable import boundary while composing the
+# rehearsal-cue projector around the existing analysis builder. Internal job
+# orchestration resolves this module global at call time, so local-audio jobs
+# and direct API callers share the same decoded-stop behavior.
+_api.build_demo_rehearsal_song = with_detected_stop_projection(_api.build_demo_rehearsal_song)
+get_analysis_status = _api.get_analysis_status
__all__ = ["build_health_report", "get_analysis_status"]
diff --git a/services/analysis-engine/src/bandscope_analysis/stop_projection.py b/services/analysis-engine/src/bandscope_analysis/stop_projection.py
new file mode 100644
index 000000000..61c75e8d0
--- /dev/null
+++ b/services/analysis-engine/src/bandscope_analysis/stop_projection.py
@@ -0,0 +1,187 @@
+"""Project signal-derived full-band cuts into rehearsal section cues.
+
+The structural segmenter intentionally models musical form at multi-second
+resolution, while stop-time detection models short full-band coordination
+breaks. This adapter keeps those responsibilities separate: it never relabels
+or rewrites a structural section. Instead, a validated stop-time moment becomes
+an additional ``stop`` rehearsal cue section anchored inside the structural
+section that owns the cut.
+
+Security Notes:
+- Operates only on already-decoded in-memory stem arrays and analysis payloads.
+- Does no file, network, subprocess, model, or persistence I/O.
+- Invalid timing, malformed section ranges, and unanchored moments are dropped.
+- Synthetic cue ids are deterministic and collision-safe within one song.
+"""
+
+from __future__ import annotations
+
+import copy
+import math
+from collections.abc import Callable
+from typing import Any, TypeVar, cast
+
+from .temporal.hits import detect_stop_time
+
+SongT = TypeVar("SongT", bound=dict[str, Any])
+BuildSong = Callable[[dict[str, Any] | None], SongT]
+
+
+def _valid_section_range(section: object) -> tuple[int, int] | None:
+ """Return a bounded positive integer section range from untrusted payload data."""
+ if not isinstance(section, dict):
+ return None
+ time_range = section.get("timeRange")
+ if not isinstance(time_range, dict):
+ return None
+ start = time_range.get("start")
+ end = time_range.get("end")
+ if (
+ not isinstance(start, int)
+ or isinstance(start, bool)
+ or not isinstance(end, int)
+ or isinstance(end, bool)
+ or start < 0
+ or end <= start
+ ):
+ return None
+ return start, end
+
+
+def _owning_section(sections: list[object], start_time: float) -> dict[str, Any] | None:
+ """Find the structural section whose half-open range owns a stop start time."""
+ for section in sections:
+ section_range = _valid_section_range(section)
+ if section_range is None or not isinstance(section, dict):
+ continue
+ start, end = section_range
+ if start <= start_time < end:
+ return section
+ return None
+
+
+def _covered_by_existing_stop(sections: list[object], start_time: float) -> bool:
+ """Avoid duplicating a stop that a future structural pipeline already emits."""
+ for section in sections:
+ if not isinstance(section, dict) or section.get("label") != "stop":
+ continue
+ section_range = _valid_section_range(section)
+ if section_range is None:
+ continue
+ start, end = section_range
+ if start <= start_time < end:
+ return True
+ return False
+
+
+def project_detected_stop_sections(
+ song: SongT,
+ audio_features: dict[str, Any] | None,
+) -> SongT:
+ """Add deterministic rehearsal cue sections for validated full-band stop moments.
+
+ Stop-time detection keeps its native 100 ms analysis resolution. The current
+ shared section contract is whole-second ``u32`` timing, so the cue section is
+ conservatively quantized to the containing structural section: floor the
+ detected start, ceil the detected end, and keep at least one second. The
+ detector's scientific output is not reinterpreted as a form label; the
+ additional ``stop`` section is explicitly a rehearsal cue projection.
+ """
+ if not isinstance(song, dict) or not isinstance(audio_features, dict):
+ return song
+
+ stems = audio_features.get("stems")
+ sr = audio_features.get("sr")
+ sections_value = song.get("sections")
+ if not isinstance(stems, dict) or not stems or not isinstance(sr, int) or sr <= 0:
+ return song
+ if not isinstance(sections_value, list) or not sections_value:
+ return song
+
+ moments = detect_stop_time(stems, sr)
+ if not moments:
+ return song
+
+ sections = list(sections_value)
+ existing_ids = {
+ section.get("id")
+ for section in sections
+ if isinstance(section, dict) and isinstance(section.get("id"), str)
+ }
+ projected: list[dict[str, Any]] = []
+ next_id = 1
+
+ for moment in moments:
+ start_time = moment.get("start_time")
+ end_time = moment.get("end_time")
+ if (
+ not isinstance(start_time, (int, float))
+ or isinstance(start_time, bool)
+ or not isinstance(end_time, (int, float))
+ or isinstance(end_time, bool)
+ ):
+ continue
+ start_time = float(start_time)
+ end_time = float(end_time)
+ if not math.isfinite(start_time) or not math.isfinite(end_time):
+ continue
+ if start_time < 0 or end_time <= start_time:
+ continue
+ if _covered_by_existing_stop(sections, start_time):
+ continue
+
+ owner = _owning_section(sections, start_time)
+ owner_range = _valid_section_range(owner)
+ if owner is None or owner_range is None:
+ continue
+ owner_start, owner_end = owner_range
+
+ cue_start = max(owner_start, math.floor(start_time))
+ cue_end = min(owner_end, max(cue_start + 1, math.ceil(end_time)))
+ if cue_end <= cue_start:
+ continue
+
+ while f"detected-stop-{next_id}" in existing_ids:
+ next_id += 1
+ cue_id = f"detected-stop-{next_id}"
+ next_id += 1
+ existing_ids.add(cue_id)
+
+ cue = copy.deepcopy(owner)
+ cue["id"] = cue_id
+ cue["label"] = "stop"
+ cue["timeRange"] = {"start": int(cue_start), "end": int(cue_end)}
+ cue["confidence"] = {
+ "level": "low",
+ "source": "model",
+ "notes": "Detected from a full-band quiet interval; confirm the cut by ear.",
+ }
+ projected.append(cue)
+
+ if not projected:
+ return song
+
+ combined = sections + projected
+ combined.sort(
+ key=lambda section: (
+ (_valid_section_range(section) or (2**63 - 1, 2**63 - 1))[0],
+ 1 if isinstance(section, dict) and section.get("label") == "stop" else 0,
+ str(section.get("id", "")) if isinstance(section, dict) else "",
+ )
+ )
+ result = dict(song)
+ result["sections"] = combined
+ return cast(SongT, result)
+
+
+def with_detected_stop_projection(build_song: BuildSong[SongT]) -> BuildSong[SongT]:
+ """Decorate the analysis-song builder at the package composition boundary."""
+ if getattr(build_song, "__bandscope_stop_projection__", False):
+ return build_song
+
+ def wrapped(audio_features: dict[str, Any] | None = None) -> SongT:
+ song = build_song(audio_features)
+ return project_detected_stop_sections(song, audio_features)
+
+ setattr(wrapped, "__bandscope_stop_projection__", True)
+ return wrapped
diff --git a/services/analysis-engine/tests/test_stop_handoff_pipeline.py b/services/analysis-engine/tests/test_stop_handoff_pipeline.py
new file mode 100644
index 000000000..df64ed555
--- /dev/null
+++ b/services/analysis-engine/tests/test_stop_handoff_pipeline.py
@@ -0,0 +1,77 @@
+"""Integration regression for real decoded-audio stop handoff evidence."""
+
+from unittest.mock import patch
+
+import numpy as np
+
+from bandscope_analysis.api import build_demo_rehearsal_song
+
+
+def _section(section_id: str, label: str, index: int) -> dict[str, object]:
+ """Build a deterministic structural section fixture for the pipeline boundary."""
+ return {
+ "id": section_id,
+ "form_label": label,
+ "sequence_index": index,
+ "groove": "standard",
+ "confidence_level": "high",
+ "confidence_source": "model",
+ "confidence_notes": "Detected from decoded audio.",
+ "cue_anchor": {"strategy": "count", "value": "Enter on beat 1"},
+ }
+
+
+def test_real_stem_stop_reaches_rehearsal_song_as_rehearsal_cue_section() -> None:
+ """A decoded full-band cut must survive analysis as an actionable stop section."""
+ sr = 1_000
+ duration_seconds = 10.0
+ sample_count = int(sr * duration_seconds)
+ time = np.arange(sample_count, dtype=np.float64) / sr
+ base = np.sin(2 * np.pi * 20 * time).astype(np.float32)
+
+ bass = base.copy()
+ drums = (0.8 * base).astype(np.float32)
+ bass[4_000:4_500] = 0.0
+ drums[4_000:4_500] = 0.0
+ stems = {"bass": bass, "drums": drums}
+
+ detected_sections = [
+ _section("verse-1", "verse", 1),
+ _section("chorus-1", "chorus", 1),
+ ]
+ boundaries = [(0.0, 5.0), (5.0, 10.0)]
+
+ with (
+ patch(
+ "bandscope_analysis.api.segment_with_boundaries",
+ return_value=(detected_sections, boundaries),
+ ),
+ patch("bandscope_analysis.ranges.pitch_tracker.PitchTracker.track", return_value=None),
+ patch(
+ "bandscope_analysis.chords.chord_recognizer.ChordRecognizer.recognize",
+ return_value=[],
+ ),
+ ):
+ song = build_demo_rehearsal_song(
+ {
+ "stems": stems,
+ "sr": sr,
+ "separation": {
+ "duration_seconds": duration_seconds,
+ "chunk_count": 1,
+ "notes": "Rights-safe integration fixture",
+ },
+ }
+ )
+
+ assert song["id"] == "analyzed-song"
+ assert [section["id"] for section in song["sections"]] == [
+ "verse-1",
+ "detected-stop-1",
+ "chorus-1",
+ ]
+ stop = song["sections"][1]
+ assert stop["label"] == "stop"
+ assert stop["timeRange"] == {"start": 4, "end": 5}
+ assert stop["confidence"]["source"] == "model"
+ assert "full-band quiet interval" in stop["confidence"]["notes"]