diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..c824f226c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# AGENTS.md
## Project overview
-- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities.
+- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and the first come-in so a sitting-out part knows where to return.
- Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts.
- Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages.
- App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..d77564148 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -82,7 +82,7 @@ Last updated: 2026-03-11
- likely harmony by section and by role
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
+ - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and the first come-in so a sitting-out part knows where to return
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..81e6dc749 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
+- Name tonight's first come-in on the ready rehearsal map and tell the player to play from the top of that section after sitting out.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..34e826ade 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into
Three layers, decoupled through shared contracts:
-- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
+- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, the next instrument check, and the first come-in so a sitting-out part knows where to return. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..0eedc7854 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -196,6 +196,72 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first come-in and the next return", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ {
+ ...verse,
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "keys-right" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 }
+ }
+ ];
+
+ render();
+
+ const callout = screen.getByTestId("first-come-in");
+ expect(callout).toHaveTextContent("Tonight's first come-in");
+ expect(callout).toHaveTextContent(
+ "Keyboard 1 Right Hand comes in on chorus. Play from the top after sitting out of verse."
+ );
+ });
+
+ it("keeps the selected active part on tonight's first come-in", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections = [
+ {
+ ...verse,
+ partGraph: verse.partGraph.map((node) =>
+ node.role_id === "lead-vocal" ? { ...node, is_active: false } : node
+ )
+ },
+ {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 }
+ }
+ ];
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("first-come-in")).toHaveTextContent(
+ "Lead Vocal comes in on chorus. Play from the top after sitting out of verse."
+ );
+ });
+
+ it("asks the player to confirm the return when every part stays active", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ expect(screen.getByTestId("first-come-in")).toHaveTextContent(
+ "Tonight's first come-in still needs a return. Confirm where the sitting-out part comes back before the first section."
+ );
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..29c6495bf 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { firstComeIn } from "./firstComeIn";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +164,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const namedComeIn = useMemo(() => firstComeIn(song, activeRole), [activeRole, song]);
+ const firstComeInCopy = namedComeIn
+ ? fillRangeCopy(t("workspaceFirstComeInNamed"), {
+ roleName: namedComeIn.roleName,
+ sectionLabel: namedComeIn.sectionLabel,
+ fromSectionLabel: namedComeIn.fromSectionLabel
+ })
+ : t("workspaceFirstComeInMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -309,6 +318,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{t("workspaceFirstRangeTitle")}
{firstRangeCopy}
+
+ {t("workspaceFirstComeInTitle")}
+ {firstComeInCopy}
+
diff --git a/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts b/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts
new file mode 100644
index 000000000..0178ac3c4
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts
@@ -0,0 +1,37 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { firstComeIn } from "./firstComeIn";
+
+function songWithSameSectionConflict(reverse: boolean): RehearsalSong {
+ const seed = createDemoRehearsalSong();
+ const template = seed.sections[0]!;
+ const bass = template.partGraph.find((node) => node.role_id === "bass-guitar")!;
+ const others = template.partGraph.filter((node) => node.role_id !== "bass-guitar");
+ const inactiveBass = { ...bass, is_active: false };
+ const activeBass = { ...bass, is_active: true };
+ const conflict = {
+ ...template,
+ id: "verse-conflict",
+ label: "verse" as RehearsalSong["sections"][number]["label"],
+ timeRange: { start: 0, end: 20 },
+ partGraph: [
+ ...others,
+ ...(reverse ? [activeBass, inactiveBass] : [inactiveBass, activeBass])
+ ]
+ };
+ const later = {
+ ...template,
+ id: "verse-later",
+ label: "verse" as RehearsalSong["sections"][number]["label"],
+ timeRange: { start: 20, end: 40 }
+ };
+ return { ...seed, sections: [conflict, later] };
+}
+
+describe("firstComeIn conflicting section evidence", () => {
+ it("fails closed for false/true and true/false duplicates before a later active section", () => {
+ for (const reverse of [false, true]) {
+ expect(firstComeIn(songWithSameSectionConflict(reverse), "bass-guitar")).toBeNull();
+ }
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstComeIn.test.ts b/apps/desktop/src/features/workspace/firstComeIn.test.ts
new file mode 100644
index 000000000..7eed56e37
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstComeIn.test.ts
@@ -0,0 +1,194 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { fillRangeCopy } from "./firstRangeSqueeze";
+import { firstComeIn } from "./firstComeIn";
+
+function withChorus(song: RehearsalSong): RehearsalSong {
+ const verse = song.sections[0]!;
+ const chorus = {
+ ...verse,
+ id: "chorus-1",
+ label: "chorus" as RehearsalSong["sections"][number]["label"],
+ timeRange: {
+ start: verse.timeRange.end,
+ end: verse.timeRange.end + 20
+ }
+ };
+ return { ...song, sections: [verse, chorus] };
+}
+
+function withSitOutThenComeIn(
+ song: RehearsalSong,
+ roleId: string,
+ sitOutActive: boolean | "omit" = false
+): RehearsalSong {
+ const twoSection = withChorus(song);
+ return {
+ ...twoSection,
+ sections: twoSection.sections.map((section, index) =>
+ index === 0
+ ? {
+ ...section,
+ partGraph: section.partGraph.map((node) => {
+ if (node.role_id !== roleId) {
+ return node;
+ }
+ if (sitOutActive === "omit") {
+ const rest: Record = {
+ role_id: node.role_id,
+ handoff_to: node.handoff_to,
+ handoff_from: node.handoff_from
+ };
+ return rest as RehearsalSong["sections"][number]["partGraph"][number];
+ }
+ return { ...node, is_active: sitOutActive };
+ })
+ }
+ : section
+ )
+ };
+}
+
+describe("firstComeIn", () => {
+ it("returns null on the demo song where every graph node is active", () => {
+ expect(firstComeIn(createDemoRehearsalSong())).toBeNull();
+ expect(firstComeIn(withChorus(createDemoRehearsalSong()))).toBeNull();
+ });
+
+ it("names the first explicit return from existing part-graph evidence", () => {
+ expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"))).toEqual({
+ sectionLabel: "chorus",
+ roleName: "Keyboard 1 Right Hand",
+ fromSectionLabel: "verse"
+ });
+ });
+
+ it("keeps the first return when a later section repeats the sit-out label", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right");
+ const repeatedVerse = {
+ ...song.sections[1]!,
+ id: "verse-2",
+ label: "verse" as RehearsalSong["sections"][number]["label"]
+ };
+ const laterChorus = {
+ ...song.sections[1]!,
+ id: "chorus-2",
+ timeRange: {
+ start: song.sections[1]!.timeRange.end,
+ end: song.sections[1]!.timeRange.end + 20
+ }
+ };
+ song.sections = [song.sections[0]!, repeatedVerse, laterChorus];
+
+ expect(firstComeIn(song)).toEqual({
+ sectionLabel: "verse",
+ roleName: "Keyboard 1 Right Hand",
+ fromSectionLabel: "verse"
+ });
+ });
+
+ it("skips blank come-in labels until a named return exists", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right");
+ song.sections[1] = {
+ ...song.sections[1]!,
+ label: "none" as RehearsalSong["sections"][number]["label"]
+ };
+ expect(firstComeIn(song)).toBeNull();
+ });
+
+ it("keeps the selected active part on tonight's first come-in", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "lead-vocal");
+ expect(firstComeIn(song, "lead-vocal")).toEqual({
+ sectionLabel: "chorus",
+ roleName: "Lead Vocal",
+ fromSectionLabel: "verse"
+ });
+ expect(firstComeIn(song, "bass-guitar")).toBeNull();
+ });
+
+ it("ignores inherited is_active evidence", () => {
+ const song = withChorus(createDemoRehearsalSong());
+ const inherited = Object.create({
+ is_active: false,
+ role_id: "keys-right"
+ }) as RehearsalSong["sections"][number]["partGraph"][number];
+ song.sections[0] = {
+ ...song.sections[0]!,
+ partGraph: [inherited, ...song.sections[0]!.partGraph]
+ };
+ expect(firstComeIn(song)).toBeNull();
+ });
+
+ it("does not treat a missing is_active flag as a sit-out", () => {
+ expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right", "omit"))).toBeNull();
+ });
+
+ it("does not treat the song's opening entrance as a come-in", () => {
+ expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right", true))).toBeNull();
+ });
+
+ it("fails closed when the return section has no named role", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right");
+ song.sections[1] = {
+ ...song.sections[1]!,
+ roles: song.sections[1]!.roles.map((role) =>
+ role.id === "keys-right" ? { ...role, name: " " } : role
+ )
+ };
+ expect(firstComeIn(song)).toBeNull();
+ });
+
+ it("rejects inherited return-section roles as naming authority", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right");
+ const returnSection = song.sections[1]!;
+ const returnWithoutOwnRoles = { ...returnSection } as Record;
+ delete returnWithoutOwnRoles.roles;
+ song.sections[1] = Object.assign(
+ Object.create({ roles: returnSection.roles }),
+ returnWithoutOwnRoles
+ ) as RehearsalSong["sections"][number];
+
+ expect(firstComeIn(song)).toBeNull();
+ });
+
+ it("fails closed when the first active return has no trustworthy role name", () => {
+ const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right");
+ const chorus = song.sections[1]!;
+ const unnamedChorus = {
+ ...chorus,
+ roles: chorus.roles.map((role) =>
+ role.id === "keys-right" ? { ...role, name: " " } : role
+ )
+ };
+ const bridge = {
+ ...chorus,
+ id: "bridge-1",
+ label: "bridge" as RehearsalSong["sections"][number]["label"],
+ timeRange: {
+ start: chorus.timeRange.end,
+ end: chorus.timeRange.end + 20
+ }
+ };
+ song.sections = [song.sections[0]!, unnamedChorus, bridge];
+
+ expect(firstComeIn(song)).toBeNull();
+ });
+
+ it("fails closed on malformed runtime roots", () => {
+ for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) {
+ expect(firstComeIn(malformed as unknown as RehearsalSong)).toBeNull();
+ }
+ });
+});
+
+describe("come-in copy filling", () => {
+ it("keeps rehearsal values literal", () => {
+ expect(
+ fillRangeCopy("{roleName} comes in on {sectionLabel} after {fromSectionLabel}.", {
+ roleName: "Bass {sectionLabel}",
+ sectionLabel: "chorus",
+ fromSectionLabel: "verse"
+ })
+ ).toBe("Bass {sectionLabel} comes in on chorus after verse.");
+ });
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/firstComeIn.ts b/apps/desktop/src/features/workspace/firstComeIn.ts
new file mode 100644
index 000000000..600cd83fd
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstComeIn.ts
@@ -0,0 +1,151 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Tonight's first named return after an explicit sit-out. */
+export type FirstComeIn = {
+ sectionLabel: string;
+ roleName: string;
+ fromSectionLabel: string;
+};
+
+/** Return whether an untrusted runtime value is a plain object record. */
+function isRuntimeObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */
+function ownActiveFlag(value: Record): boolean | null {
+ if (!Object.prototype.hasOwnProperty.call(value, "is_active")) {
+ return null;
+ }
+ if (value.is_active === true) {
+ return true;
+ }
+ if (value.is_active === false) {
+ return false;
+ }
+ return null;
+}
+
+/**
+ * Resolve a named role from a section's own-property `roles` list.
+ *
+ * Blank, `none`, inherited, or non-string names are not rehearsal authority.
+ */
+function namedRoleOnSection(
+ sectionValue: Record,
+ roleId: string
+): string | undefined {
+ if (
+ !Object.prototype.hasOwnProperty.call(sectionValue, "roles") ||
+ !Array.isArray(sectionValue.roles)
+ ) {
+ return undefined;
+ }
+ for (const roleValue of sectionValue.roles) {
+ if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) {
+ continue;
+ }
+ if (meaningfulRangeText(roleValue.id) !== roleId) {
+ continue;
+ }
+ return meaningfulRangeText(
+ Object.prototype.hasOwnProperty.call(roleValue, "name") ? roleValue.name : undefined
+ );
+ }
+ return undefined;
+}
+
+type SitOutEvidence = {
+ sectionIndex: number;
+ sectionLabel: string;
+};
+
+/**
+ * Pick the first explicit return a player should take after sitting out.
+ *
+ * Uses existing `partGraph` `is_active` authority already produced by
+ * analysis. A come-in is the first later named section where a part that
+ * sat out (`is_active: false`) is own-property active again. Section identity
+ * is tracked by iteration index so repeated form labels remain valid later
+ * returns while false/true evidence inside one section is rejected. Inherited
+ * `is_active`, inherited role registries, missing graph nodes, blank labels,
+ * unnamed roles, and malformed roots fail closed. When a role is selected,
+ * only that part's return is named.
+ */
+export function firstComeIn(
+ song: RehearsalSong | unknown,
+ activeRole: string | null = null
+): FirstComeIn | null {
+ if (!isRuntimeObject(song) || !Array.isArray(song.sections)) {
+ return null;
+ }
+
+ const sittingOut = new Map();
+
+ for (const [sectionIndex, sectionValue] of song.sections.entries()) {
+ if (!isRuntimeObject(sectionValue)) {
+ continue;
+ }
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ if (!sectionLabel) {
+ continue;
+ }
+ if (!Array.isArray(sectionValue.partGraph)) {
+ continue;
+ }
+
+ const sectionActivity = new Map();
+ for (const nodeValue of sectionValue.partGraph) {
+ if (
+ !isRuntimeObject(nodeValue) ||
+ !Object.prototype.hasOwnProperty.call(nodeValue, "role_id")
+ ) {
+ continue;
+ }
+ const roleId = meaningfulRangeText(nodeValue.role_id);
+ if (!roleId) {
+ continue;
+ }
+ if (activeRole && roleId !== activeRole) {
+ continue;
+ }
+
+ const activeFlag = ownActiveFlag(nodeValue);
+ if (activeFlag === null) {
+ continue;
+ }
+ const existingFlag = sectionActivity.get(roleId);
+ if (existingFlag !== undefined && existingFlag !== activeFlag) {
+ return null;
+ }
+ if (existingFlag === undefined) {
+ sectionActivity.set(roleId, activeFlag);
+ }
+ }
+
+ for (const [roleId, activeFlag] of sectionActivity) {
+ if (activeFlag === false) {
+ if (!sittingOut.has(roleId)) {
+ sittingOut.set(roleId, { sectionIndex, sectionLabel });
+ }
+ continue;
+ }
+
+ const sitOut = sittingOut.get(roleId);
+ if (!sitOut || sitOut.sectionIndex === sectionIndex) {
+ continue;
+ }
+
+ const roleName = namedRoleOnSection(sectionValue, roleId);
+ if (!roleName) {
+ sittingOut.delete(roleId);
+ continue;
+ }
+
+ return { sectionLabel, roleName, fromSectionLabel: sitOut.sectionLabel };
+ }
+ }
+
+ return null;
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..79d67b465 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -60,6 +60,7 @@ describe("i18n", () => {
const t = createTranslator("ko");
expect(t("appTitle")).toBe("BandScope");
expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구");
+ expect(t("workspaceFirstComeInTitle")).toBe("오늘 먼저 들어올 자리");
});
it("falls back to English when a Korean translation is missing", () => {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..d91a723ea 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,9 @@
"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.",
+ "workspaceFirstComeInTitle": "Tonight's first come-in",
+ "workspaceFirstComeInNamed": "{roleName} comes in on {sectionLabel}. Play from the top after sitting out of {fromSectionLabel}.",
+ "workspaceFirstComeInMissing": "Tonight's first come-in still needs a return. Confirm where the sitting-out part comes back before the first section.",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..5d6866f0a 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,9 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstComeInTitle": "오늘 먼저 들어올 자리",
+ "workspaceFirstComeInNamed": "{roleName}은 {fromSectionLabel}에서 쉰 뒤 {sectionLabel}부터 들어옵니다. 그 구간 처음부터 연주하세요.",
+ "workspaceFirstComeInMissing": "오늘 먼저 들어올 자리는 아직 없습니다. 첫 구간 전에 쉬는 파트가 어디서 다시 들어오는지 확인해 보세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..d49c275b4 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
- `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`.
- `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy.
- `EmptyState` must remain an actionable state card, not a blank placeholder panel.
+- Ready `Workspace` names tonight's first playable range and tonight's first come-in so the map enables the next rehearsal action without opening files or export paths.
- If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging.
## Pattern Backlog
diff --git a/docs/doctoring/first-come-in.md b/docs/doctoring/first-come-in.md
new file mode 100644
index 000000000..355ed2bbc
--- /dev/null
+++ b/docs/doctoring/first-come-in.md
@@ -0,0 +1,17 @@
+# Tonight's first come-in
+
+The ready rehearsal map names the first explicit return from existing `partGraph` evidence: a part that sat out (`is_active: false`) on a named section and is own-property active again on a later named section. This is where the player should come back in so the band does not miss the entrance after a rest. It is not a tacet, dropout, handoff, Fine, last-line breath, or the song's opening entrance.
+
+## Next action
+
+- Named: play from the top of the named section after sitting out of the earlier section.
+- Missing: confirm where the sitting-out part comes back before the first section.
+
+## Security Notes
+
+- Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project.
+- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property `is_active: false` followed by a later own-property `is_active: true` on a named section.
+- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a come-in. Inherited `is_active` is isolated. Same-section false-then-true nodes are not a return. When a role is selected, only that part's own-property return is named.
+- Safe failure: inherited flags, blank labels, missing names, opening entrances without a prior sit-out, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a return.
+- Logging/privacy: rejected or accepted come-ins are not logged. Copy interpolation keeps rehearsal values literal.
+- Tests: `firstComeIn.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys return on chorus, selected-role scoping, inherited flags, missing `is_active`, opening-entrance rejection, unnamed roles, and literal copy filling.