diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx
new file mode 100644
index 000000000..415f1b8b1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx
@@ -0,0 +1,174 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import {
+ createDemoRehearsalSong,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstDropoutCallout } from "./FirstDropoutCallout";
+
+function appendSongStructureTarget() {
+ const grid = document.createElement("div");
+ grid.dataset.testid = "song-structure-grid";
+ const target = document.createElement("div");
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(target, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView
+ });
+ grid.appendChild(target);
+ document.body.appendChild(grid);
+ return { grid, scrollIntoView };
+}
+
+describe("FirstDropoutCallout", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("names the first dropout as map navigation, scrolls to its rendered section, and arms that action", () => {
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render(
);
+
+ const action = screen.getByRole("button", {
+ name: "Open Bass Guitar dropout for Lead Vocal at 0:30"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).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 Bass Guitar dropout for Lead Vocal at 0:30"
+ })
+ );
+
+ expect(
+ screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).")
+ ).toBeTruthy();
+ expect(
+ screen.queryByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)
+ ).toBeNull();
+ });
+
+ it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => {
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+ const onHearDropout = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "Open Bass Guitar dropout for Lead Vocal at 0:30"
+ })
+ );
+ expect(onHearDropout).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 = createDemoRehearsalSong();
+ song.sections[0]!.id = "analysis section / duplicate";
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render(
);
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "Open Bass Guitar dropout for Lead Vocal at 0:30"
+ })
+ );
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+
+ grid.remove();
+ });
+
+ it("shows fresh guidance when the first dropout changes or returns later", () => {
+ const initialSong = createDemoRehearsalSong();
+ const { grid } = appendSongStructureTarget();
+ const { rerender } = render(
);
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "Open Bass Guitar dropout for Lead Vocal at 0:30"
+ })
+ );
+ expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy();
+
+ const replacementSong = createDemoRehearsalSong();
+ replacementSong.id = "demo-song-replacement";
+ replacementSong.sections[0]!.roles[0]!.name = "Upright Bass";
+ rerender(
);
+ expect(screen.getByText("Upright Bass hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy();
+
+ rerender(
);
+ expect(screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy();
+
+ grid.remove();
+ });
+
+ it("keeps placeholder-looking rehearsal data literal", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0]!.name = "{section}";
+
+ render(
);
+
+ expect(
+ screen.getByRole("button", {
+ name: "Open {section} dropout for Lead Vocal at 0:30"
+ })
+ ).toBeTruthy();
+ });
+
+ it("tells the room to stay on the map when no dropout exists", () => {
+ const song = createDemoRehearsalSong();
+ song.sections = [];
+ render(
);
+ expect(
+ screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.")
+ ).toBeTruthy();
+ });
+
+ it("contains a malformed runtime song root instead of crashing the callout", () => {
+ render(
);
+
+ expect(
+ screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.")
+ ).toBeTruthy();
+ });
+
+ it("localizes the section form and keeps Korean role interpolation free of unsafe fixed particles", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0]!.name = "베이스 기타";
+ song.sections[0]!.roles[2]!.name = "리드 보컬";
+ const { grid } = appendSongStructureTarget();
+
+ render(
);
+
+ expect(screen.getByText("0:30 벌스 끝 파트 인계: 베이스 기타 → 리드 보컬.")).toBeTruthy();
+ expect(screen.queryByText(/verse 끝/)).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: /드롭아웃 위치 열기/ }));
+ expect(
+ screen.getByText("0:30 벌스: 리드 보컬 진입 전에 베이스 기타의 마지막 마디를 시작하세요.")
+ ).toBeTruthy();
+
+ grid.remove();
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx
new file mode 100644
index 000000000..b8cd40fe5
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx
@@ -0,0 +1,148 @@
+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 { formatDropoutTime, resolveFirstDropoutHandoff } from "./firstDropoutHandoff";
+
+/** Props for the first-dropout rehearsal callout. */
+export interface FirstDropoutCalloutProps {
+ song: RehearsalSong;
+ actionMode?: "workspace-scroll" | "callback-only";
+ onHearDropout?: (endSeconds: number) => void;
+}
+
+type DropoutCopyValues = Readonly
>;
+
+type HeardDropout = Readonly<{
+ songId: string;
+ sectionId: string;
+ sectionIndex: number;
+ fromRoleId: string;
+ toRoleId: string;
+ endSeconds: number;
+}>;
+
+/** Interpolate dropout placeholders once so rehearsal data is never rescanned as template syntax. */
+function formatDropoutCopy(template: string, values: DropoutCopyValues): string {
+ return template.replace(/\{(from|to|section|end)\}/g, (placeholder) => {
+ const key = placeholder.slice(1, -1) as keyof DropoutCopyValues;
+ return values[key] ?? placeholder;
+ });
+}
+
+/** Name tonight's first dropout and offer only an action that the current surface can execute. */
+export function FirstDropoutCallout({
+ song,
+ actionMode = "workspace-scroll",
+ onHearDropout
+}: FirstDropoutCalloutProps) {
+ const locale = detectPreferredLocale();
+ const t = createTranslator(locale);
+ const runtimeSong = song as unknown as Partial | null;
+ const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : "";
+ const handoff = resolveFirstDropoutHandoff(song);
+ const handoffSectionIndex =
+ handoff && Array.isArray(runtimeSong?.sections)
+ ? runtimeSong.sections.indexOf(handoff.section)
+ : -1;
+ const [heardDropout, setHeardDropout] = useState(null);
+
+ useEffect(() => {
+ setHeardDropout(null);
+ }, [
+ songId,
+ handoffSectionIndex,
+ handoff?.section.id,
+ handoff?.fromRole.id,
+ handoff?.toRole.id,
+ handoff?.endSeconds
+ ]);
+
+ if (!handoff) {
+ return (
+
+ );
+ }
+
+ const heard =
+ heardDropout?.songId === songId &&
+ heardDropout.sectionId === handoff.section.id &&
+ heardDropout.sectionIndex === handoffSectionIndex &&
+ heardDropout.fromRoleId === handoff.fromRole.id &&
+ heardDropout.toRoleId === handoff.toRole.id &&
+ heardDropout.endSeconds === handoff.endSeconds;
+ const end = formatDropoutTime(handoff.endSeconds);
+ const copyValues: DropoutCopyValues = {
+ from: handoff.fromRole.name,
+ to: handoff.toRole.name,
+ section: translateSectionFormLabel(locale, handoff.section.label),
+ end
+ };
+ const actionLabel = formatDropoutCopy(
+ t(actionMode === "callback-only" ? "firstDropoutAction" : "firstDropoutOpenAction"),
+ copyValues
+ );
+ const body = formatDropoutCopy(t("firstDropoutBody"), copyValues);
+ const armed = formatDropoutCopy(t("firstDropoutArmed"), copyValues);
+ const canExecuteAction =
+ actionMode === "workspace-scroll" || typeof onHearDropout === "function";
+
+ /** Record completion only after the owning surface executes the selected dropout action. */
+ const markDropoutActionComplete = () => {
+ setHeardDropout({
+ songId,
+ sectionId: handoff.section.id,
+ sectionIndex: handoffSectionIndex,
+ fromRoleId: handoff.fromRole.id,
+ toRoleId: handoff.toRole.id,
+ endSeconds: handoff.endSeconds
+ });
+ };
+
+ return (
+
+ {t("firstDropoutLabel")}
+ {heard ? armed : body}
+ {canExecuteAction ? (
+ {
+ if (actionMode === "callback-only") {
+ onHearDropout!(handoff.endSeconds);
+ markDropoutActionComplete();
+ return;
+ }
+ const grid = document.querySelector('[data-testid="song-structure-grid"]');
+ const target = handoffSectionIndex >= 0 ? grid?.children.item(handoffSectionIndex) : null;
+ if (typeof target?.scrollIntoView !== "function") {
+ return;
+ }
+ target.scrollIntoView({
+ block: "nearest",
+ behavior: "smooth"
+ });
+ markDropoutActionComplete();
+ }}
+ >
+ {actionLabel}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..6accefbdc 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -32,7 +32,6 @@ describe("Workspace", () => {
it("updates practice progress immutably through onSongUpdate", () => {
const song = createDemoRehearsalSong();
- // Default mock setup puts "bass-guitar" as the role ID in index 0
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "bass-guitar",
@@ -42,21 +41,13 @@ describe("Workspace", () => {
const onSongUpdate = vi.fn();
render( );
-
- // Select the Bass Guitar role to render PracticeProgress
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
-
- const increaseBtn = screen.getByRole("button", { name: "Increase progress" });
- fireEvent.click(increaseBtn);
+ fireEvent.click(screen.getByRole("button", { name: "Increase progress" }));
expect(onSongUpdate).toHaveBeenCalledTimes(1);
const updatedSong = onSongUpdate.mock.calls[0]?.[0] as RehearsalSong;
-
- // Ensure immutable update logic: reference equality of untouched sections
expect(updatedSong).not.toBe(song);
expect(updatedSong.sections).not.toBe(song.sections);
-
- // Ensure the specific role progress updated
expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60);
});
@@ -67,11 +58,21 @@ describe("Workspace", () => {
render( );
const grid = screen.getByTestId("song-structure-grid");
-
expect(grid.style.gridTemplateColumns).not.toContain("repeat(0");
expect(grid.style.gridTemplateColumns).toContain("repeat(1");
});
+ it("keeps analysis section ids out of song-structure DOM authority", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.id = "analysis section / duplicate";
+
+ render( );
+
+ const firstRenderedSection = screen.getByTestId("song-structure-grid").children.item(0);
+ expect(firstRenderedSection).toBeTruthy();
+ expect(firstRenderedSection?.hasAttribute("id")).toBe(false);
+ });
+
it("falls back to safe timeline text for malformed section times", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -81,7 +82,6 @@ describe("Workspace", () => {
};
render( );
-
expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
@@ -326,4 +326,24 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
-});
+
+ it("names tonight's first dropout as workspace navigation", () => {
+ render( );
+
+ const section = screen.getByTestId("song-structure-grid").children.item(0);
+ expect(section).toBeTruthy();
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(section!, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView
+ });
+
+ const action = screen.getByRole("button", {
+ name: "Open Bass Guitar dropout for Lead Vocal at 0:30"
+ });
+ fireEvent.click(action);
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy();
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..9e651f881 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 { FirstDropoutCallout } from "./FirstDropoutCallout";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -91,8 +92,8 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
data-testid="song-structure-grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }}
>
- {sections.map((section) => (
-
+ {sections.map((section, sectionIndex) => (
+
{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}
@@ -353,6 +354,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+
+
diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts
new file mode 100644
index 000000000..1ceef295d
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff";
+
+const runtimeStringFalse = "false" as unknown as boolean;
+
+describe("resolveFirstDropoutHandoff activity-type authority", () => {
+ it("does not treat a string false flag as an active dropout source", () => {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ section.partGraph = section.partGraph.map((node) =>
+ node.role_id === "bass-guitar" ? { ...node, is_active: runtimeStringFalse } : node
+ );
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts
new file mode 100644
index 000000000..bef2dbd1e
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff";
+
+describe("resolveFirstDropoutHandoff runtime role identity", () => {
+ it("ignores a handoff source whose runtime role id is not a non-empty string", () => {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+
+ const malformedSource = {
+ ...section.roles[0]!,
+ id: 42 as unknown as string,
+ name: "Malformed Runtime Source",
+ rehearsalPriority: "high" as const
+ };
+ const safeSource = {
+ ...section.roles[1]!,
+ id: "safe-keys",
+ name: "Safe Keys",
+ rehearsalPriority: "medium" as const
+ };
+ const receiver = {
+ ...section.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high" as const
+ };
+
+ section.roles = [malformedSource, safeSource, receiver];
+ section.partGraph = [
+ {
+ role_id: 42 as unknown as string,
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "safe-keys",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: [42 as unknown as string, "safe-keys"]
+ }
+ ];
+ song.sections = [section];
+
+ expect(resolveFirstDropoutHandoff(song)?.fromRole.id).toBe("safe-keys");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts
new file mode 100644
index 000000000..d1c7c3907
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts
@@ -0,0 +1,123 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ createDemoRehearsalSong,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff";
+
+function runtimeSong(value: unknown): RehearsalSong {
+ return value as RehearsalSong;
+}
+
+function songWithLaterValidDropout() {
+ const song = createDemoRehearsalSong();
+ const valid = structuredClone(song.sections[0]!);
+ valid.id = "later-valid-dropout";
+ valid.label = "chorus";
+ valid.timeRange = { start: 40, end: 70 };
+ return { song, valid };
+}
+
+describe("first dropout runtime boundary", () => {
+ it.each([null, 42])("fails closed when the runtime song root is %s", (value) => {
+ const song = runtimeSong(value);
+
+ expect(() => resolveFirstDropoutHandoff(song)).not.toThrow();
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("fails closed when the runtime section collection is not an array", () => {
+ const song = createDemoRehearsalSong();
+ (song as unknown as { sections: unknown }).sections = null;
+
+ expect(() => resolveFirstDropoutHandoff(song)).not.toThrow();
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("rejects sparse section evidence instead of skipping the missing entry", () => {
+ const song = createDemoRehearsalSong();
+ const sparseSections: typeof song.sections = new Array(2);
+ sparseSections[1] = song.sections[0]!;
+ song.sections = sparseSections;
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("ignores malformed section elements and preserves a later valid dropout", () => {
+ const { song, valid } = songWithLaterValidDropout();
+ song.sections = [null, valid] as unknown as typeof song.sections;
+
+ expect(resolveFirstDropoutHandoff(song)?.section.id).toBe("later-valid-dropout");
+ });
+
+ it.each([
+ { start: 10, end: 10 },
+ { start: 10.5, end: 11.5 },
+ null
+ ])("rejects an invalid runtime window %j and preserves a later valid dropout", (timeRange) => {
+ const { song, valid } = songWithLaterValidDropout();
+ const invalid = structuredClone(valid);
+ invalid.id = "invalid-window";
+ invalid.timeRange = timeRange as typeof invalid.timeRange;
+ song.sections = [invalid, valid];
+
+ expect(resolveFirstDropoutHandoff(song)?.section.id).toBe("later-valid-dropout");
+ });
+
+ it("rejects a dropout window above the shared section-time ceiling", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.timeRange = {
+ start: MAX_SECTION_TIME_SECONDS,
+ end: MAX_SECTION_TIME_SECONDS + 1
+ };
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("fails a section closed when role or graph collections are sparse", () => {
+ const roleSparseSong = createDemoRehearsalSong();
+ const roleSection = roleSparseSong.sections[0]!;
+ const sparseRoles: typeof roleSection.roles = new Array(roleSection.roles.length + 1);
+ roleSection.roles.forEach((role, index) => {
+ sparseRoles[index + 1] = role;
+ });
+ roleSection.roles = sparseRoles;
+
+ const graphSparseSong = createDemoRehearsalSong();
+ const graphSection = graphSparseSong.sections[0]!;
+ const sparseGraph: typeof graphSection.partGraph = new Array(graphSection.partGraph.length + 1);
+ graphSection.partGraph.forEach((node, index) => {
+ sparseGraph[index + 1] = node;
+ });
+ graphSection.partGraph = sparseGraph;
+
+ expect(() => resolveFirstDropoutHandoff(roleSparseSong)).not.toThrow();
+ expect(resolveFirstDropoutHandoff(roleSparseSong)).toBeNull();
+ expect(() => resolveFirstDropoutHandoff(graphSparseSong)).not.toThrow();
+ expect(resolveFirstDropoutHandoff(graphSparseSong)).toBeNull();
+ });
+
+ it("rejects sparse handoff edge collections as incomplete evidence", () => {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ const outgoing = section.partGraph.find((node) => node.role_id === "bass-guitar")!;
+ const incoming = section.partGraph.find((node) => node.role_id === "lead-vocal")!;
+ const sparseTo: string[] = new Array(2);
+ sparseTo[1] = "lead-vocal";
+ const sparseFrom: string[] = new Array(2);
+ sparseFrom[1] = "bass-guitar";
+ outgoing.handoff_to = sparseTo;
+ incoming.handoff_from = sparseFrom;
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("rejects a buyer-visible holder whose runtime name is not a non-empty string", () => {
+ const song = createDemoRehearsalSong();
+ const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!;
+ (bass as unknown as { name: unknown }).name = { secret: "not-copy" };
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts
new file mode 100644
index 000000000..c6d1c7616
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts
@@ -0,0 +1,332 @@
+import { describe, expect, it } from "vitest";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { formatDropoutTime, resolveFirstDropoutHandoff } from "./firstDropoutHandoff";
+
+describe("resolveFirstDropoutHandoff", () => {
+ it("picks the earliest explicit handoff, not a later entrance", () => {
+ const song = createDemoRehearsalSong();
+ const handoff = resolveFirstDropoutHandoff(song);
+
+ expect(handoff?.section.id).toBe("verse-1");
+ expect(handoff?.fromRole.id).toBe("bass-guitar");
+ expect(handoff?.toRole.id).toBe("lead-vocal");
+ expect(handoff?.endSeconds).toBe(30);
+ expect(formatDropoutTime(handoff?.endSeconds ?? -1)).toBe("0:30");
+ expect(formatDropoutTime(Number.NaN)).toBe("0:00");
+ });
+
+ it("returns null when no part hands off", () => {
+ const song = createDemoRehearsalSong();
+ song.sections = [];
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("skips an earlier section that only has inactive or empty handoff lists", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const later = structuredClone(verse);
+ later.id = "chorus-1";
+ later.label = "chorus";
+ later.timeRange = { start: 40, end: 70 };
+ later.roles = [
+ {
+ ...verse.roles[0]!,
+ id: "bass-guitar-chorus",
+ name: "Bass Guitar"
+ },
+ {
+ ...verse.roles[2]!,
+ id: "lead-vocal-chorus",
+ name: "Lead Vocal"
+ }
+ ];
+ later.partGraph = [
+ {
+ role_id: "bass-guitar-chorus",
+ is_active: true,
+ handoff_to: ["lead-vocal-chorus"],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal-chorus",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: ["bass-guitar-chorus"]
+ }
+ ];
+ song.sections = [
+ {
+ ...verse,
+ partGraph: [
+ {
+ role_id: "bass-guitar",
+ is_active: false,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "keys-right",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ]
+ },
+ later
+ ];
+
+ const handoff = resolveFirstDropoutHandoff(song);
+ expect(handoff?.section.id).toBe("chorus-1");
+ expect(handoff?.fromRole.id).toBe("bass-guitar-chorus");
+ expect(handoff?.toRole.id).toBe("lead-vocal-chorus");
+ expect(handoff?.endSeconds).toBe(70);
+ });
+
+ it("does not resolve a section handoff against a role that exists only in another section", () => {
+ const song = createDemoRehearsalSong();
+ const verse = structuredClone(song.sections[0]!);
+ verse.partGraph = [
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["future-lead"],
+ handoff_from: []
+ }
+ ];
+
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 70 };
+ chorus.roles = [
+ {
+ ...chorus.roles[0]!,
+ id: "chorus-bass",
+ rehearsalPriority: "medium"
+ },
+ {
+ ...chorus.roles[2]!,
+ id: "future-lead",
+ rehearsalPriority: "high"
+ }
+ ];
+ chorus.partGraph = [
+ {
+ role_id: "chorus-bass",
+ is_active: true,
+ handoff_to: ["future-lead"],
+ handoff_from: []
+ },
+ {
+ role_id: "future-lead",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: ["chorus-bass"]
+ }
+ ];
+ song.sections = [verse, chorus];
+
+ const handoff = resolveFirstDropoutHandoff(song);
+ expect(handoff?.section.id).toBe("chorus-1");
+ expect(handoff?.fromRole.id).toBe("chorus-bass");
+ expect(handoff?.toRole.id).toBe("future-lead");
+ });
+
+ it("rejects an outgoing handoff that the target node does not corroborate", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ verse.partGraph = verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, handoff_from: [] } : node
+ );
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("fails closed when a section contains duplicate role identities", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const duplicateLead = structuredClone(
+ verse.roles.find((role) => role.id === "lead-vocal")!
+ );
+ duplicateLead.name = "Shadow Lead";
+ verse.roles = [...verse.roles, duplicateLead];
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("fails closed when a section contains duplicate graph-node identities", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ verse.partGraph = [
+ ...verse.partGraph,
+ {
+ role_id: "lead-vocal",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+
+ expect(resolveFirstDropoutHandoff(song)).toBeNull();
+ });
+
+ it("accepts a reciprocal receiver that is inactive until the next section", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ verse.partGraph = verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ );
+
+ const handoff = resolveFirstDropoutHandoff(song);
+ expect(handoff?.fromRole.id).toBe("bass-guitar");
+ expect(handoff?.toRole.id).toBe("lead-vocal");
+ });
+
+ it("prefers the higher-priority outgoing part when two handoffs share a section", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections[0] = {
+ ...verse,
+ roles: verse.roles.map((role) =>
+ role.id === "keys-right" ? { ...role, rehearsalPriority: "low" as const } : role
+ ),
+ partGraph: [
+ {
+ role_id: "keys-right",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ },
+ {
+ role_id: "lead-vocal",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: ["keys-right", "bass-guitar"]
+ }
+ ]
+ };
+
+ expect(resolveFirstDropoutHandoff(song)?.fromRole.id).toBe("bass-guitar");
+ });
+
+ it("prefers the earlier dropout when a later section also hands off", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const later = structuredClone(verse);
+ later.id = "bridge-1";
+ later.label = "bridge";
+ later.timeRange = { start: 80, end: 100 };
+ later.roles = [
+ {
+ ...verse.roles[1]!,
+ id: "keys-bridge",
+ name: "Keyboard 1 Right Hand",
+ rehearsalPriority: "high"
+ },
+ {
+ ...verse.roles[2]!,
+ id: "vocal-bridge",
+ name: "Lead Vocal"
+ }
+ ];
+ later.partGraph = [
+ {
+ role_id: "keys-bridge",
+ is_active: true,
+ handoff_to: ["vocal-bridge"],
+ handoff_from: []
+ },
+ {
+ role_id: "vocal-bridge",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: ["keys-bridge"]
+ }
+ ];
+ song.sections = [verse, later];
+
+ const handoff = resolveFirstDropoutHandoff(song);
+ expect(handoff?.section.id).toBe("verse-1");
+ expect(handoff?.fromRole.id).toBe("bass-guitar");
+ expect(handoff?.endSeconds).toBe(30);
+ });
+
+ it("skips non-finite ends, unknown priorities, missing roles, and self-handoffs", () => {
+ const song = createDemoRehearsalSong();
+ const invalidEnd = structuredClone(song.sections[0]!);
+ invalidEnd.id = "invalid-end";
+ invalidEnd.timeRange = { start: 0, end: Number.NaN };
+ invalidEnd.partGraph = [
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: ["lead-vocal"],
+ handoff_from: []
+ }
+ ];
+
+ const validSection = structuredClone(song.sections[0]!);
+ validSection.id = "valid-chorus";
+ validSection.label = "chorus";
+ validSection.timeRange = { start: 20, end: 50 };
+ const invalidPriorityRole = {
+ ...validSection.roles[0]!,
+ id: "invalid-priority"
+ };
+ (invalidPriorityRole as unknown as { rehearsalPriority: string }).rehearsalPriority = "urgent";
+ validSection.roles = [
+ invalidPriorityRole,
+ {
+ ...validSection.roles[2]!,
+ id: "safe-lead",
+ rehearsalPriority: "high"
+ },
+ {
+ ...validSection.roles[0]!,
+ id: "safe-bass",
+ name: "Bass Guitar",
+ rehearsalPriority: "medium"
+ }
+ ];
+ validSection.partGraph = [
+ {
+ role_id: "missing-role",
+ is_active: true,
+ handoff_to: ["safe-lead"],
+ handoff_from: []
+ },
+ {
+ role_id: "invalid-priority",
+ is_active: true,
+ handoff_to: ["safe-lead"],
+ handoff_from: []
+ },
+ {
+ role_id: "safe-bass",
+ is_active: true,
+ handoff_to: ["safe-bass", " ", "nobody", "safe-lead"],
+ handoff_from: []
+ },
+ {
+ role_id: "safe-lead",
+ is_active: false,
+ handoff_to: [],
+ handoff_from: ["safe-bass"]
+ }
+ ];
+
+ song.sections = [invalidEnd, validSection];
+
+ const handoff = resolveFirstDropoutHandoff(song);
+ expect(handoff?.section.id).toBe("valid-chorus");
+ expect(handoff?.fromRole.id).toBe("safe-bass");
+ expect(handoff?.toRole.id).toBe("safe-lead");
+ expect(handoff?.endSeconds).toBe(50);
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts
new file mode 100644
index 000000000..38a1bc37b
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts
@@ -0,0 +1,237 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ SECTION_FORM_LABELS,
+ type RehearsalRole,
+ type RehearsalSection,
+ type RehearsalSong,
+ type SectionFormLabel
+} from "@bandscope/shared-types";
+
+const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const;
+
+type PartGraphNode = RehearsalSection["partGraph"][number];
+
+/** Tonight's first dropout: earliest section handoff, then the highest-priority outgoing part. */
+export type FirstDropoutHandoff = {
+ section: RehearsalSection;
+ fromRole: RehearsalRole;
+ toRole: RehearsalRole;
+ endSeconds: number;
+};
+
+/** Format a non-negative dropout time as m:ss for rehearsal copy. */
+export function formatDropoutTime(totalSeconds: number): string {
+ const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0;
+ const minutes = Math.floor(safeSeconds / 60);
+ const seconds = Math.floor(safeSeconds % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+/** Return whether an untrusted runtime value can be inspected as an object. */
+function isRuntimeObject(value: unknown): value is object {
+ return value !== null && typeof value === "object";
+}
+
+/** Return whether every numeric index is present in a bounded runtime array. */
+function isDenseRuntimeArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ const length = Number(value.length);
+ if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
+ return false;
+ }
+ for (let index = 0; index < length; index += 1) {
+ if (!(index in value)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** Return true when every section-local identity is unambiguous. */
+function hasUniqueIdentities(ids: readonly string[]): boolean {
+ return new Set(ids).size === ids.length;
+}
+
+/** Return whether one role has safe buyer-visible identity and copy. */
+function hasSafeRoleIdentity(role: unknown): role is RehearsalRole {
+ if (!isRuntimeObject(role)) {
+ return false;
+ }
+ const candidate = role as Partial;
+ return (
+ typeof candidate.id === "string" &&
+ candidate.id.trim().length > 0 &&
+ typeof candidate.name === "string" &&
+ candidate.name.trim().length > 0
+ );
+}
+
+/** Return true when the safe role also has a ranked rehearsal priority. */
+function hasRankedPriority(role: RehearsalRole): boolean {
+ return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority);
+}
+
+/** Return the usable role ids in a complete edge array, or null for sparse evidence. */
+function safeRoleIds(value: unknown): string[] | null {
+ if (!isDenseRuntimeArray(value)) {
+ return null;
+ }
+ return value.filter(
+ (roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0
+ );
+}
+
+/** Return whether one graph node has safe section-local identity and complete edge arrays. */
+function isSafeGraphNode(value: unknown): value is PartGraphNode {
+ if (!isRuntimeObject(value)) {
+ return false;
+ }
+ const candidate = value as Partial;
+ return (
+ typeof candidate.role_id === "string" &&
+ candidate.role_id.trim().length > 0 &&
+ isDenseRuntimeArray(candidate.handoff_to) &&
+ isDenseRuntimeArray(candidate.handoff_from)
+ );
+}
+
+/** Return whether one section has safe identity, form, and a bounded positive integer window. */
+function hasBoundedSectionWindow(value: unknown): value is RehearsalSection {
+ if (!isRuntimeObject(value)) {
+ return false;
+ }
+ const section = value as Partial;
+ 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 (
+ typeof section.id === "string" &&
+ section.id.trim().length > 0 &&
+ typeof section.label === "string" &&
+ SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) &&
+ Number.isInteger(start) &&
+ start >= 0 &&
+ start <= MAX_SECTION_TIME_SECONDS &&
+ Number.isInteger(end) &&
+ end > start &&
+ end <= MAX_SECTION_TIME_SECONDS
+ );
+}
+
+/** Return the non-empty string identity carried by an untrusted object, when present. */
+function runtimeIdentity(value: unknown, key: "id" | "role_id"): string | null {
+ if (!isRuntimeObject(value)) {
+ return null;
+ }
+ const identity = (value as Record)[key];
+ return typeof identity === "string" && identity.trim().length > 0 ? identity : null;
+}
+
+/** Require the receiving graph node to corroborate the outgoing edge. */
+function hasReciprocalHandoff(
+ graphNodes: readonly PartGraphNode[],
+ fromRoleId: string,
+ toRoleId: string
+): boolean {
+ return graphNodes.some((candidate) => {
+ const incomingRoleIds = safeRoleIds(candidate.handoff_from);
+ return (
+ candidate.role_id === toRoleId &&
+ incomingRoleIds !== null &&
+ incomingRoleIds.includes(fromRoleId)
+ );
+ });
+}
+
+/** Return the first validated section-local dropout, or null when no safe candidate remains. */
+export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHandoff | null {
+ if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) {
+ return null;
+ }
+
+ const sections = song.sections
+ .filter(hasBoundedSectionWindow)
+ .sort((left, right) => left.timeRange.start - right.timeRange.start);
+
+ const candidates: FirstDropoutHandoff[] = [];
+
+ for (const section of sections) {
+ if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) {
+ continue;
+ }
+
+ const roleIdentities = section.roles
+ .map((role) => runtimeIdentity(role, "id"))
+ .filter((identity): identity is string => identity !== null);
+ const graphIdentities = section.partGraph
+ .map((node) => runtimeIdentity(node, "role_id"))
+ .filter((identity): identity is string => identity !== null);
+ if (!hasUniqueIdentities(roleIdentities) || !hasUniqueIdentities(graphIdentities)) {
+ continue;
+ }
+
+ const safeRoles = section.roles.filter(hasSafeRoleIdentity);
+ const safeGraphNodes = section.partGraph.filter(isSafeGraphNode);
+ const rolesInSection = new Map(safeRoles.map((role) => [role.id, role]));
+
+ for (const node of safeGraphNodes) {
+ const handoffTargets = safeRoleIds(node.handoff_to);
+ if (node.is_active !== true || handoffTargets === null || handoffTargets.length === 0) {
+ continue;
+ }
+
+ const fromRole = rolesInSection.get(node.role_id);
+ if (!fromRole || !hasRankedPriority(fromRole)) {
+ continue;
+ }
+
+ const targets = handoffTargets
+ .map((roleId) => rolesInSection.get(roleId) ?? null)
+ .filter(
+ (role): role is RehearsalRole =>
+ role !== null &&
+ hasRankedPriority(role) &&
+ role.id !== fromRole.id &&
+ hasReciprocalHandoff(safeGraphNodes, fromRole.id, role.id)
+ );
+
+ if (targets.length === 0) {
+ continue;
+ }
+
+ const toRole = [...targets].sort(
+ (left, right) => PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]
+ )[0];
+ if (!toRole) {
+ continue;
+ }
+
+ candidates.push({
+ section,
+ fromRole,
+ toRole,
+ endSeconds: section.timeRange.end
+ });
+ }
+ }
+
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ candidates.sort((left, right) => {
+ if (left.endSeconds !== right.endSeconds) {
+ return left.endSeconds - right.endSeconds;
+ }
+ return PRIORITY_RANK[left.fromRole.rehearsalPriority] - PRIORITY_RANK[right.fromRole.rehearsalPriority];
+ });
+
+ return candidates[0] ?? null;
+}
\ No newline at end of file
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..d76317a8f 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -149,6 +149,13 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
+ "firstDropoutLabel": "Tonight's first dropout",
+ "firstDropoutAction": "Hear {from} drop out for {to} at {end}",
+ "firstDropoutOpenAction": "Open {from} dropout for {to} at {end}",
+ "firstDropoutBody": "{from} hands off to {to} at the end of the {section} ({end}).",
+ "firstDropoutArmed": "Start the last bar of {from} before {to} takes the {section} ({end}).",
+ "firstDropoutUnavailable": "No dropout yet. Stay on tonight's map until a part hands off.",
+ "firstDropoutNeedsSong": "Analyze tonight's song first, then hear the first dropout 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..ab6ca56dd 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,13 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstDropoutLabel": "오늘 첫 드롭아웃",
+ "firstDropoutAction": "{end}에 {to}에게 넘기고 빠지는 {from} 듣기",
+ "firstDropoutOpenAction": "{end} {section}의 {from}→{to} 드롭아웃 위치 열기",
+ "firstDropoutBody": "{end} {section} 끝 파트 인계: {from} → {to}.",
+ "firstDropoutArmed": "{end} {section}: {to} 진입 전에 {from}의 마지막 마디를 시작하세요.",
+ "firstDropoutUnavailable": "아직 드롭아웃이 없습니다. 파트가 넘기는 순간이 생길 때까지 오늘 지도에 머무르세요.",
+ "firstDropoutNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 드롭아웃을 들으세요.",
"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..a11cabcc9 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 Dropout Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstDropoutCallout.tsx` | Name the outgoing part, incoming part, section, and end 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 `onHearDropout` exists and delegates the exact handoff 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()`. |