From ca3319b3046be9f34f22abfdef379d935f8c3364 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 08:17:04 +0000 Subject: [PATCH 1/8] feat(workspace): name tonight's first leftover last-dropout remaining last-return tutti on the map The ready rehearsal map names leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return so the band counts that all-in from the top. Leftover last-dropout remaining last-return that is already all-in stays leftover last-dropout remaining last-return. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 220 +++++++ .../src/features/workspace/Workspace.tsx | 33 + ...iningLastReturnTutti.selected-role.test.ts | 88 +++ ...astDropoutRemainingLastReturnTutti.test.ts | 587 +++++++++++++++++ ...overLastDropoutRemainingLastReturnTutti.ts | 599 ++++++++++++++++++ apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + docs/design-system/component-contract.md | 1 + ...ast-dropout-remaining-last-return-tutti.md | 18 + 13 files changed, 1558 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts create mode 100644 docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..fd1b9ca3a 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, leftover last-dropout remaining last-return tutti cues after leftover last-dropout remaining last-return, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. - 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..5e1545d44 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 and tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-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..de0fc0e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first leftover last-dropout remaining last-return tutti on the ready rehearsal map and tell the player the band is all in after leftover last-dropout remaining last-return. - 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. - 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..54b64952b 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 and tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-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..0e275cdb6 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -153,6 +153,226 @@ describe("Workspace", () => { ); }); + it("names tonight's first leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || + node.role_id === "keys-right" || + 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 }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" || node.role_id === "lead-vocal" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "outro-1", + label: "outro", + timeRange: { + start: verse.timeRange.end + 40, + end: verse.timeRange.end + 60 + } + }, + { + ...verse, + id: "tag-1", + label: "tag", + timeRange: { + start: verse.timeRange.end + 60, + end: verse.timeRange.end + 80 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" || node.role_id === "bass-guitar" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "coda-1", + label: "stop", + timeRange: { + start: verse.timeRange.end + 80, + end: verse.timeRange.end + 100 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "ending-1", + label: "ending", + timeRange: { + start: verse.timeRange.end + 100, + end: verse.timeRange.end + 120 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "fine-1", + label: "fine", + timeRange: { + start: verse.timeRange.end + 120, + end: verse.timeRange.end + 140 + } + } + ]; + + render(); + + const callout = screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti"); + expect(callout).toHaveTextContent("Tonight's first leftover last-dropout remaining last-return tutti"); + expect(callout).toHaveTextContent( + "Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Count the leftover last-dropout remaining last-return tutti in from the top of fine." + ); + }); + + it("tells leftover last-dropout remaining last-return tutti to come in together after leftover last-dropout remaining last-return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || + node.role_id === "keys-right" || + 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 }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" || node.role_id === "lead-vocal" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "outro-1", + label: "outro", + timeRange: { + start: verse.timeRange.end + 40, + end: verse.timeRange.end + 60 + } + }, + { + ...verse, + id: "tag-1", + label: "tag", + timeRange: { + start: verse.timeRange.end + 60, + end: verse.timeRange.end + 80 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" || node.role_id === "bass-guitar" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "coda-1", + label: "stop", + timeRange: { + start: verse.timeRange.end + 80, + end: verse.timeRange.end + 100 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "ending-1", + label: "ending", + timeRange: { + start: verse.timeRange.end + 100, + end: verse.timeRange.end + 120 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "fine-1", + label: "fine", + timeRange: { + start: verse.timeRange.end + 120, + end: verse.timeRange.end + 140 + } + } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent( + "Bass Guitar comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine. Come in together from the top of fine." + ); + }); + + it("asks the player to confirm leftover last-dropout remaining last-return tutti when every part stays active", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent( + "Tonight's first leftover last-dropout remaining last-return tutti still needs leftover last-dropout remaining last-return that is not already all-in, then a later all-in. Confirm leftover last-dropout remaining last-return is all-in before the first section." + ); + }); + it("asks for an ear check when the selected part has no named span", () => { 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..e2d74ea2c 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 { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,26 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const namedLeftoverLastDropoutRemainingLastReturnTutti = useMemo( + () => firstLeftoverLastDropoutRemainingLastReturnTutti(song, activeRole), + [activeRole, song] + ); + const firstLeftoverLastDropoutRemainingLastReturnTuttiCopy = namedLeftoverLastDropoutRemainingLastReturnTutti + ? fillRangeCopy( + t( + activeRole && activeRole === namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleId + ? "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn" + : "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed" + ), + { + remainingRoleName: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingRoleName, + sectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.sectionLabel, + remainingSectionLabel: namedLeftoverLastDropoutRemainingLastReturnTutti.remainingSectionLabel, + remainingLastReturnSectionLabel: + namedLeftoverLastDropoutRemainingLastReturnTutti.remainingLastReturnSectionLabel + } + ) + : t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +330,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

+ {t("workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle")} +

+

+ {firstLeftoverLastDropoutRemainingLastReturnTuttiCopy} +

+
diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts new file mode 100644 index 000000000..cc54e99c6 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts @@ -0,0 +1,88 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: RehearsalSong["sections"][number]["label"], + start: number, + inactiveRoleIds: readonly string[] +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + return { + ...template, + id, + label, + timeRange: { start, end: start + 20 }, + partGraph: template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })) + }; +} + +describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search", () => { + it("keeps searching after the selected part newly drops out during leftover last-dropout remaining last-return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]), + sectionWithInactiveRoles(template, "fine-1", "fine", 140, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + }); + + it("does not tell leftover last-dropout remaining last-return without leftover last-dropout remaining last-return tutti to come in together", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "bass-guitar")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts new file mode 100644 index 000000000..cb51fbed5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts @@ -0,0 +1,587 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstLeftoverLastDropoutRemainingLastReturnTutti } from "./firstLeftoverLastDropoutRemainingLastReturnTutti"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: string, + start: number, + inactiveRoleIds: readonly string[], + activeOnlyRoles = false +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + const partGraph = template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })); + return { + ...template, + id, + label: label as RehearsalSong["sections"][number]["label"], + timeRange: { start, end: start + 20 }, + partGraph, + roles: activeOnlyRoles + ? template.roles.filter((role) => !inactive.has(role.id)) + : template.roles + }; +} + +function leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti( + remainingRoleId = "bass-guitar", + returningDropoutId = "keys-right", + lastRoleId = "lead-vocal", + returningLeftoverId = "keys-right", + originalSitOutRoleId = "bass-guitar", + leftoverAtLastReturnId = "lead-vocal" +): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + return { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + originalSitOutRoleId, + returningLeftoverId, + lastRoleId + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + returningLeftoverId, + lastRoleId + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [lastRoleId]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, [ + remainingRoleId, + returningDropoutId + ]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, [remainingRoleId]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, [leftoverAtLastReturnId]), + sectionWithInactiveRoles(template, "fine-1", "fine", 140, []) + ] + }; +} + +describe("firstLeftoverLastDropoutRemainingLastReturnTutti", () => { + it("returns null on the demo song where every graph node is active", () => { + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(createDemoRehearsalSong())).toBeNull(); + }); + + it("names leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return", () => { + expect( + firstLeftoverLastDropoutRemainingLastReturnTutti( + leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti() + ) + ).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + }); + + it("uses song-wide role names when inactive analysis roles are omitted from section roles", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "opening-1", "opening", 0, []), + sectionWithInactiveRoles( + template, + "bridge-1", + "bridge", + 20, + ["bass-guitar", "keys-right", "lead-vocal"], + true + ), + sectionWithInactiveRoles( + template, + "chorus-1", + "chorus", + 40, + ["keys-right", "lead-vocal"], + true + ), + sectionWithInactiveRoles(template, "tag-1", "tag", 60, ["lead-vocal"], true), + sectionWithInactiveRoles(template, "outro-1", "outro", 80, [], true), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["keys-right", "bass-guitar"], true), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["bass-guitar"], true), + sectionWithInactiveRoles(template, "wait-1", "wait", 140, ["lead-vocal"], true), + sectionWithInactiveRoles(template, "fine-1", "fine", 160, [], true) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "wait", + remainingSectionLabel: "ending", + dropoutSectionLabel: "stop", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "bridge", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + }); + + it("does not treat leftover last-dropout remaining last-return that is already all-in as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("fails closed when leftover last-dropout remaining last-return never tuttis", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("keeps searching after continued leftover at leftover last-dropout remaining last-return until tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]), + sectionWithInactiveRoles(template, "wait-1", "wait", 140, ["lead-vocal"]), + sectionWithInactiveRoles(template, "fine-1", "fine", 160, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + }); + + it("keeps the selected leftover last-dropout remaining last-return tutti on tonight's first leftover last-dropout remaining last-return tutti", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "bass-guitar")).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "keys-right")).toEqual({ + sectionLabel: "fine", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "bass-guitar", + remainingRoleName: "Bass Guitar" + }); + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "missing-role")).toBeNull(); + }); + + it("does not treat leftover last-dropout remaining as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat leftover last-dropout return with nobody still out as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat leftover last-return as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat remaining leftover at leftover return as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a leftover sit-out without leftover return as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + expect( + firstLeftoverLastDropoutRemainingLastReturnTutti({ + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]) + ] + }) + ).toBeNull(); + }); + + it("does not treat a leftover return with nobody still out as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a come-in without a leftover sit-out as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["bass-guitar"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a tutti after a full original return as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a new dropout after remaining leftover as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar", "lead-vocal"]) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a singleton leftover last-dropout return as leftover last-dropout remaining last-return tutti", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right"]), + sectionWithInactiveRoles(template, "coda-1", "stop", 100, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("ignores inherited is_active evidence", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + 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(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("does not treat a missing is_active flag as leftover-last-dropout-remaining-last-return-tutti evidence", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + song.sections[1] = { + ...song.sections[1]!, + partGraph: song.sections[1]!.partGraph.map((node) => { + if (node.role_id !== "keys-right") { + return node; + } + 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]; + }) + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("fails closed on contradictory duplicate graph identities", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + const section = song.sections[7]!; + const bassNode = section.partGraph.find((node) => node.role_id === "bass-guitar")!; + const withoutBass = section.partGraph.filter((node) => node.role_id !== "bass-guitar"); + song.sections[7] = { + ...section, + partGraph: [...withoutBass, { ...bassNode, is_active: true }, { ...bassNode, is_active: false }] + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("skips blank leftover-last-dropout-remaining-last-return-tutti labels until a named leftover last-dropout remaining last-return tutti exists", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + song.sections[7] = { + ...song.sections[7]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("fails closed when leftover last-dropout remaining last-return tutti has no named leftover role", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + song.sections[0] = { + ...song.sections[0]!, + roles: song.sections[0]!.roles.map((role) => ({ ...role, name: " " })) + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("fails closed when a later section has no named graph", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + song.sections[7] = { + ...song.sections[7]!, + partGraph: [] + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect( + firstLeftoverLastDropoutRemainingLastReturnTutti(malformed as unknown as RehearsalSong) + ).toBeNull(); + } + }); + + it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => { + const song = leftoverThenLastReturnThenDropoutThenRemainingThenLastReturnThenTutti(); + song.sections[0] = { + ...song.sections[0]!, + partGraph: [ + { role_id: " ", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "ghost", is_active: false, handoff_to: [], handoff_from: [] }, + { + role_id: "keys-right", + is_active: "no" as unknown as boolean, + handoff_to: [], + handoff_from: [] + }, + ...song.sections[0]!.partGraph + ] + }; + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song)).toBeNull(); + }); +}); + +describe("leftover-last-dropout-remaining-last-return-tutti copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy( + "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}.", + { + remainingRoleName: "Bass Guitar {sectionLabel}", + remainingLastReturnSectionLabel: "ending", + remainingSectionLabel: "stop", + sectionLabel: "fine" + } + ) + ).toBe( + "Bass Guitar {sectionLabel} comes back at ending after leftover last-dropout remaining at stop, and the band is all in at fine." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts new file mode 100644 index 000000000..25cf2670b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts @@ -0,0 +1,599 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named leftover last-dropout remaining last-return tutti. */ +export type FirstLeftoverLastDropoutRemainingLastReturnTutti = { + sectionLabel: string; + remainingLastReturnSectionLabel: string; + remainingSectionLabel: string; + dropoutSectionLabel: string; + lastReturnSectionLabel: string; + leftoverSectionLabel: string; + fromSectionLabel: string; + remainingRoleId: string; + remainingRoleName: 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; +} + +type NamedRoleCatalog = Map; + +/** + * Build trustworthy role identity evidence across the whole song. + * + * Production analysis emits active-only section `roles` while keeping inactive + * identities in `partGraph`. The song-wide catalog therefore lets leftover + * last-dropout remaining last-return tutti keep its trustworthy display name + * across leftover sit-out, remaining leftover, leftover last-return, leftover + * last-dropout, leftover last-dropout remaining, leftover last-dropout + * remaining last-return, and leftover last-dropout remaining last-return tutti. + */ +function namedSongRoles(songValue: Record): NamedRoleCatalog | null { + if (!Array.isArray(songValue.sections)) { + return null; + } + + const namedRoles: NamedRoleCatalog = new Map(); + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return null; + } + + const sectionRoleIds = new Set(); + for (const roleValue of sectionValue.roles) { + if ( + !isRuntimeObject(roleValue) || + !Object.prototype.hasOwnProperty.call(roleValue, "id") || + !Object.prototype.hasOwnProperty.call(roleValue, "name") + ) { + return null; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName || sectionRoleIds.has(roleId)) { + return null; + } + sectionRoleIds.add(roleId); + + const knownName = namedRoles.get(roleId); + if (knownName && knownName !== roleName) { + return null; + } + namedRoles.set(roleId, roleName); + } + } + + return namedRoles.size > 0 ? namedRoles : null; +} + +type NamedGraphNode = { + roleId: string; + active: boolean; +}; + +/** + * Collect one complete, unique activity record for every song-wide named role. + * + * Missing, unknown, duplicate, inherited, or non-boolean graph evidence fails + * closed so leftover last-dropout remaining cannot be both remaining and + * returning in the same leftover last-dropout remaining last-return tutti + * decision. + */ +function namedGraphNodes( + sectionValue: Record, + namedRoles: NamedRoleCatalog +): NamedGraphNode[] | null { + if (!Array.isArray(sectionValue.partGraph)) { + return null; + } + + const nodes: NamedGraphNode[] = []; + const seenRoleIds = new Set(); + for (const nodeValue of sectionValue.partGraph) { + if ( + !isRuntimeObject(nodeValue) || + !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") + ) { + return null; + } + + const roleId = meaningfulRangeText(nodeValue.role_id); + if (!roleId || !namedRoles.has(roleId) || seenRoleIds.has(roleId)) { + return null; + } + + const active = ownActiveFlag(nodeValue); + if (active === null) { + return null; + } + + seenRoleIds.add(roleId); + nodes.push({ roleId, active }); + } + + return seenRoleIds.size === namedRoles.size ? nodes : null; +} + +type PendingLeftoverSitOut = { + leftoverSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + originalSitOutIds: string[]; +}; + +type PendingRemainingLeftover = { + leftoverSectionLabel: string; + remainingSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + remainingIds: string[]; + originalSitOutIds: string[]; +}; + +type PendingLastReturn = { + leftoverSectionLabel: string; + remainingSectionLabel: string; + lastReturnSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + remainingIds: string[]; + originalSitOutIds: string[]; + lastRoleId: string; +}; + +type PendingDropout = { + leftoverSectionLabel: string; + remainingSectionLabel: string; + lastReturnSectionLabel: string; + dropoutSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + remainingIds: string[]; + originalSitOutIds: string[]; + lastRoleId: string; + dropoutIds: string[]; +}; + +type PendingRemainingDropout = { + leftoverSectionLabel: string; + remainingSectionLabel: string; + lastReturnSectionLabel: string; + dropoutSectionLabel: string; + remainingDropoutSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + remainingIds: string[]; + originalSitOutIds: string[]; + lastRoleId: string; + dropoutIds: string[]; + remainingDropoutIds: string[]; + returningDropoutIds: string[]; +}; + +type PendingTutti = PendingRemainingDropout & { + remainingLastReturnSectionLabel: string; + leftoverAtLastReturnIds: string[]; +}; + +/** + * Return whether the selected part belongs to this leftover last-dropout + * remaining last-return tutti. + * + * A leftover last-dropout remaining last-return tutti is shown only after + * leftover last-dropout remaining last-return whose original sit-out, leftover, + * remaining leftover, last leftover, leftover last-dropout, leftover last-dropout + * remaining, leftover last-dropout remaining last-return, leftover at leftover + * last-dropout remaining last-return, or leftover last-dropout remaining + * last-return tutti includes that selected part, so a silent always-active part + * is never told to count someone in. + */ +function selectedPartBelongs( + pending: PendingRemainingDropout | PendingTutti, + activeRole: string | null +): boolean { + if (!activeRole) { + return true; + } + const leftoverAtLastReturnIds = + "leftoverAtLastReturnIds" in pending ? pending.leftoverAtLastReturnIds : []; + return ( + pending.originalSitOutIds.includes(activeRole) || + pending.leftoverIds.includes(activeRole) || + pending.remainingIds.includes(activeRole) || + pending.lastRoleId === activeRole || + pending.dropoutIds.includes(activeRole) || + pending.remainingDropoutIds.includes(activeRole) || + pending.returningDropoutIds.includes(activeRole) || + leftoverAtLastReturnIds.includes(activeRole) + ); +} + +/** + * Pick the first leftover last-dropout remaining last-return tutti a player + * should honor. + * + * A leftover sit-out is the first later named section where at least one member + * of the current reduced cohort has returned and at least one remains out. A + * leftover return with remaining leftover is the first named section after that + * leftover sit-out where at least one leftover part is own-property active and + * at least one leftover remains own-property tacet. A leftover last-return is + * the first later named section where every remaining leftover is own-property + * active. A leftover last-dropout is the first later named section after that + * leftover last-return where at least two named parts are own-property tacet. + * A leftover last-dropout remaining is the first later named section after that + * leftover last-dropout where at least one leftover last-dropout named part is + * own-property active and at least one leftover last-dropout remains + * own-property tacet. A leftover last-dropout remaining last-return is the first + * later named section after leftover last-dropout remaining where every leftover + * last-dropout remaining named part is own-property active and at least one + * named part remains own-property tacet. A leftover last-dropout remaining + * last-return tutti is the first later named section after leftover last-dropout + * remaining last-return where every named graph role is own-property active. + * Leftover last-dropout remaining last-return that is already all-in, leftover + * last-dropout remaining, leftover last-dropout, leftover last-dropout return + * with nobody still out, leftover last-return, remaining leftover, leftover + * sit-out, leftover return, come-in, tacet, tutti after leftover last-return + * without leftover last-dropout remaining last-return, new dropout after + * remaining leftover, leftover last-dropout without leftover last-dropout + * remaining, leftover last-dropout remaining last-return without a later tutti, + * or a singleton leftover last-dropout is not leftover last-dropout remaining + * last-return tutti. + * + * Inherited/missing activity, incomplete or contradictory graphs, unnamed + * roles, and malformed runtime data fail closed. When a role is selected, a + * leftover last-dropout remaining last-return tutti is shown only after leftover + * last-dropout remaining last-return that includes that named part, leftover at + * leftover last-dropout remaining last-return of that named part, or leftover + * last-dropout remaining last-return tutti of that named part. + */ +export function firstLeftoverLastDropoutRemainingLastReturnTutti( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstLeftoverLastDropoutRemainingLastReturnTutti | null { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return null; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles || (activeRole && !namedRoles.has(activeRole))) { + return null; + } + + let reducedFrom: string | null = null; + let sittingOutIds: Set | null = null; + let pendingSitOut: PendingLeftoverSitOut | null = null; + let pendingRemaining: PendingRemainingLeftover | null = null; + let pendingLastReturn: PendingLastReturn | null = null; + let pendingDropout: PendingDropout | null = null; + let pendingRemainingDropout: PendingRemainingDropout | null = null; + let pendingTutti: PendingTutti | null = null; + + for (const sectionValue of song.sections) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + const nodes = namedGraphNodes(sectionValue, namedRoles); + if (!nodes) { + return null; + } + + const sittingOut = nodes.filter((node) => node.active === false); + + if (pendingTutti) { + const pending = pendingTutti; + if (activeRole && !selectedPartBelongs(pending, activeRole)) { + continue; + } + if (sittingOut.length === 0) { + return { + sectionLabel, + remainingLastReturnSectionLabel: pending.remainingLastReturnSectionLabel, + remainingSectionLabel: pending.remainingDropoutSectionLabel, + dropoutSectionLabel: pending.dropoutSectionLabel, + lastReturnSectionLabel: pending.lastReturnSectionLabel, + leftoverSectionLabel: pending.leftoverSectionLabel, + fromSectionLabel: pending.fromSectionLabel, + remainingRoleId: pending.lastRoleId, + remainingRoleName: namedRoles.get(pending.lastRoleId)! + }; + } + continue; + } + + if (pendingRemainingDropout) { + const remaining: PendingRemainingDropout = pendingRemainingDropout; + const remainingNodes = remaining.remainingDropoutIds.map((remainingId) => + nodes.find((node) => node.roleId === remainingId) + ); + if (remainingNodes.some((node) => !node)) { + return null; + } + + const returningLast = remainingNodes.filter((node) => node!.active); + const stillRemaining = remainingNodes.filter((node) => node!.active === false); + + if (returningLast.length > 0 && stillRemaining.length === 0) { + let last = returningLast[0]!; + if (activeRole) { + if (!selectedPartBelongs(remaining, activeRole) && sittingOut.every((node) => node.roleId !== activeRole)) { + continue; + } + const selectedLast = returningLast.find((node) => node!.roleId === activeRole); + if (selectedLast) { + last = selectedLast; + } + } + if (sittingOut.length === 0) { + pendingRemainingDropout = null; + reducedFrom = null; + sittingOutIds = null; + continue; + } + pendingTutti = { + ...remaining, + lastRoleId: last.roleId, + remainingLastReturnSectionLabel: sectionLabel, + leftoverAtLastReturnIds: sittingOut.map((node) => node.roleId) + }; + pendingRemainingDropout = null; + continue; + } + + if (returningLast.length > 0 && stillRemaining.length > 0) { + pendingRemainingDropout = { + ...remaining, + remainingDropoutIds: stillRemaining.map((node) => node!.roleId) + }; + } + continue; + } + + if (pendingDropout) { + const dropout = pendingDropout; + const dropoutNodes = dropout.dropoutIds.map((dropoutId) => + nodes.find((node) => node.roleId === dropoutId) + ); + if (dropoutNodes.some((node) => !node)) { + return null; + } + + const returningDropouts = dropoutNodes.filter((node) => node!.active); + const remainingDropouts = dropoutNodes.filter((node) => node!.active === false); + + if (returningDropouts.length > 0 && remainingDropouts.length > 0) { + if (activeRole) { + const selectedDropout = remainingDropouts.find((node) => node!.roleId === activeRole); + if ( + !selectedDropout && + !dropout.originalSitOutIds.includes(activeRole) && + !dropout.leftoverIds.includes(activeRole) && + !dropout.remainingIds.includes(activeRole) && + dropout.lastRoleId !== activeRole && + !dropout.dropoutIds.includes(activeRole) + ) { + continue; + } + } + pendingRemainingDropout = { + leftoverSectionLabel: dropout.leftoverSectionLabel, + remainingSectionLabel: dropout.remainingSectionLabel, + lastReturnSectionLabel: dropout.lastReturnSectionLabel, + dropoutSectionLabel: dropout.dropoutSectionLabel, + remainingDropoutSectionLabel: sectionLabel, + fromSectionLabel: dropout.fromSectionLabel, + leftoverIds: dropout.leftoverIds, + remainingIds: dropout.remainingIds, + originalSitOutIds: dropout.originalSitOutIds, + lastRoleId: dropout.lastRoleId, + dropoutIds: dropout.dropoutIds, + remainingDropoutIds: remainingDropouts.map((node) => node!.roleId), + returningDropoutIds: returningDropouts.map((node) => node!.roleId) + }; + pendingDropout = null; + continue; + } + + if (returningDropouts.length > 0 && remainingDropouts.length === 0) { + pendingDropout = null; + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } + continue; + } + + if (pendingLastReturn) { + if (sittingOut.length === 0) { + continue; + } + + if (sittingOut.length < 2) { + pendingLastReturn = null; + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } + + if (activeRole) { + const selectedDropout = sittingOut.find((node) => node.roleId === activeRole); + if ( + !selectedDropout && + !pendingLastReturn.originalSitOutIds.includes(activeRole) && + !pendingLastReturn.leftoverIds.includes(activeRole) && + !pendingLastReturn.remainingIds.includes(activeRole) && + pendingLastReturn.lastRoleId !== activeRole + ) { + continue; + } + } + + pendingDropout = { + leftoverSectionLabel: pendingLastReturn.leftoverSectionLabel, + remainingSectionLabel: pendingLastReturn.remainingSectionLabel, + lastReturnSectionLabel: pendingLastReturn.lastReturnSectionLabel, + dropoutSectionLabel: sectionLabel, + fromSectionLabel: pendingLastReturn.fromSectionLabel, + leftoverIds: pendingLastReturn.leftoverIds, + remainingIds: pendingLastReturn.remainingIds, + originalSitOutIds: pendingLastReturn.originalSitOutIds, + lastRoleId: pendingLastReturn.lastRoleId, + dropoutIds: sittingOut.map((node) => node.roleId) + }; + pendingLastReturn = null; + continue; + } + + if (pendingRemaining) { + const remainingNodes: NamedGraphNode[] = []; + for (const remainingId of pendingRemaining.remainingIds) { + const remainingNode = nodes.find((node) => node.roleId === remainingId); + if (!remainingNode) { + return null; + } + remainingNodes.push(remainingNode); + } + + const returningLast = remainingNodes.filter((node) => node.active); + const stillRemaining = remainingNodes.filter((node) => node.active === false); + + if (returningLast.length > 0 && stillRemaining.length === 0) { + let last = returningLast[0]!; + if (activeRole) { + const activeRoleNode = nodes.find((node) => node.roleId === activeRole); + if (!activeRoleNode) { + return null; + } + const selectedLast = returningLast.find((node) => node.roleId === activeRole); + if (selectedLast) { + last = selectedLast; + } + } + pendingLastReturn = { + leftoverSectionLabel: pendingRemaining.leftoverSectionLabel, + remainingSectionLabel: pendingRemaining.remainingSectionLabel, + lastReturnSectionLabel: sectionLabel, + fromSectionLabel: pendingRemaining.fromSectionLabel, + leftoverIds: pendingRemaining.leftoverIds, + remainingIds: pendingRemaining.remainingIds, + originalSitOutIds: pendingRemaining.originalSitOutIds, + lastRoleId: last.roleId + }; + pendingRemaining = null; + continue; + } + + if (returningLast.length > 0 && stillRemaining.length > 0) { + pendingRemaining = { + leftoverSectionLabel: pendingRemaining.leftoverSectionLabel, + remainingSectionLabel: sectionLabel, + fromSectionLabel: pendingRemaining.fromSectionLabel, + leftoverIds: pendingRemaining.leftoverIds, + remainingIds: stillRemaining.map((node) => node.roleId), + originalSitOutIds: pendingRemaining.originalSitOutIds + }; + } + continue; + } + + if (pendingSitOut) { + const leftoverNodes = pendingSitOut.leftoverIds.map((leftoverId) => + nodes.find((node) => node.roleId === leftoverId) + ); + if (leftoverNodes.some((node) => !node)) { + return null; + } + + const returningLeftovers = leftoverNodes.filter((node) => node!.active); + const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false); + + if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) { + pendingRemaining = { + leftoverSectionLabel: pendingSitOut.leftoverSectionLabel, + remainingSectionLabel: sectionLabel, + fromSectionLabel: pendingSitOut.fromSectionLabel, + leftoverIds: pendingSitOut.leftoverIds, + remainingIds: remainingLeftovers.map((node) => node!.roleId), + originalSitOutIds: pendingSitOut.originalSitOutIds + }; + pendingSitOut = null; + continue; + } + + if (returningLeftovers.length > 0 && remainingLeftovers.length === 0) { + pendingSitOut = null; + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } + continue; + } + + if (!sittingOutIds || !reducedFrom) { + if (sittingOut.length === 0) { + continue; + } + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } + + const baselineIds = sittingOutIds; + const returning = nodes.filter( + (node) => node.active === true && baselineIds.has(node.roleId) + ); + const leftovers = sittingOut.filter((node) => baselineIds.has(node.roleId)); + + if (returning.length > 0 && leftovers.length > 0) { + pendingSitOut = { + leftoverSectionLabel: sectionLabel, + fromSectionLabel: reducedFrom, + leftoverIds: leftovers.map((node) => node.roleId), + originalSitOutIds: [...baselineIds] + }; + continue; + } + + if (returning.length === baselineIds.size && leftovers.length === 0) { + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } + } + + return null; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..8f97315b9 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,10 @@ "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.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "Tonight's first leftover last-dropout remaining last-return tutti", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Count the leftover last-dropout remaining last-return tutti in from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Come in together from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "Tonight's first leftover last-dropout remaining last-return tutti still needs leftover last-dropout remaining last-return that is not already all-in, then a later all-in. Confirm leftover last-dropout remaining last-return is all-in 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..91352d009 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "오늘 먼저 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 후 투티", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 투티를 넣으세요.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 같이 들어오세요.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "오늘 먼저 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 투티는 아직 확인이 필요합니다. 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 뒤 전원이 모이는지 첫 구간 전에 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..d8fc6a194 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 leftover last-dropout remaining last-return tutti 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-leftover-last-dropout-remaining-last-return-tutti.md b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md new file mode 100644 index 000000000..ce8bacb79 --- /dev/null +++ b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md @@ -0,0 +1,18 @@ +# Tonight's first leftover last-dropout remaining last-return tutti + +The ready rehearsal map names the first leftover last-dropout remaining last-return tutti from existing `partGraph` evidence: a named leftover sit-out, then a later named leftover return where at least one leftover named part is own-property active and at least one leftover remains own-property tacet, then a later named leftover last-return where every remaining leftover is own-property active, then a later named leftover last-dropout where at least two named parts are own-property tacet, then a later named leftover last-dropout remaining where at least one leftover last-dropout named part is own-property active and at least one leftover last-dropout remains own-property tacet, then a later named leftover last-dropout remaining last-return where every leftover last-dropout remaining named part is own-property active and at least one named part remains own-property tacet, then a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. This is when the band is all in after leftover last-dropout remaining last-return. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover at leftover return, leftover last-return, leftover last-dropout, leftover last-dropout remaining, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout return with nobody still out, tutti, handoff, Fine, last-line breath, a singleton leftover last-dropout, leftover last-dropout remaining last-return without a later tutti, a new dropout after remaining leftover, or a new MIR product. + +## Next action + +- Named leftover last-dropout remaining last-return tutti: come in together from the top of the named leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return. +- Named returning or other included part: count the leftover last-dropout remaining last-return tutti in from the top of that tutti. +- Missing leftover last-dropout remaining last-return tutti: confirm leftover last-dropout remaining last-return is all-in before the first section. + +## Security Notes + +- Untrusted input: `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 an own-property leftover sit-out, leftover return with remaining leftover, leftover last-return, leftover last-dropout cohort of at least two named parts, leftover last-dropout remaining, leftover last-dropout remaining last-return that is not already all-in, and a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not leftover last-dropout remaining last-return tutti. Inherited `is_active` is isolated. Leftover last-dropout remaining last-return that is already all-in is leftover last-dropout remaining last-return, not leftover last-dropout remaining last-return tutti. Leftover last-dropout remaining is leftover last-dropout remaining, not leftover last-dropout remaining last-return tutti. A leftover last-dropout return with nobody still out is leftover last-dropout return, not leftover last-dropout remaining last-return tutti. A leftover last-return is leftover last-return, not leftover last-dropout remaining last-return tutti. Remaining leftover at leftover return is remaining leftover, not leftover last-dropout remaining last-return tutti. A singleton leftover last-dropout cannot leave leftover last-dropout remaining last-return tutti. A new dropout after remaining leftover is a dropout, not leftover last-dropout remaining last-return tutti. All-active later sections after leftover last-return are tuttis, not leftover last-dropout remaining last-return tutti, until leftover last-dropout remaining last-return exists and later tuttis. When a role is selected, only leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return that includes that named part, leftover at leftover last-dropout remaining last-return of that named part, or leftover last-dropout remaining last-return tutti of that named part, is shown. +- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without leftover return, leftover last-dropouts without leftover last-dropout remaining, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns with nobody still out, singleton leftover last-dropouts, come-ins without a leftover, full-band returns, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing leftover last-dropout remaining last-return tutti. +- Logging/privacy: rejected or accepted leftover last-dropout remaining last-return tutti cues are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts`, selected-role search, and the Workspace callout cover the demo all-active case, an explicit bass leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return, selected-role scoping, inherited flags, missing `is_active`, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns, singleton leftover last-dropouts, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and literal copy filling. From fbeced2aba38b74760ee0e5af26534ea1fb0c3dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:11:15 +0900 Subject: [PATCH 2/8] test(workspace): keep searching past unrelated tutti --- ...iningLastReturnTutti.selected-role.test.ts | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts index cc54e99c6..0c74885bc 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts @@ -22,6 +22,33 @@ function sectionWithInactiveRoles( }; } +function sectionTemplateWithObserver( + template: RehearsalSong["sections"][number] +): RehearsalSong["sections"][number] { + const referenceRole = template.roles[0]!; + const referenceNode = template.partGraph[0]!; + return { + ...template, + roles: [ + ...template.roles, + { + ...referenceRole, + id: "observer", + name: "Observer" + } + ], + partGraph: [ + ...template.partGraph, + { + ...referenceNode, + role_id: "observer", + handoff_to: [], + handoff_from: [] + } + ] + }; +} + describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search", () => { it("keeps searching after the selected part newly drops out during leftover last-dropout remaining last-return", () => { const seed = createDemoRehearsalSong(); @@ -60,6 +87,58 @@ describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search" }); }); + it("keeps searching after an earlier completed sequence excludes the selected part", () => { + const seed = createDemoRehearsalSong(); + const template = sectionTemplateWithObserver(seed.sections[0]!); + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "stop-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "outro", 120, ["lead-vocal"]), + sectionWithInactiveRoles(template, "fine-1", "outro", 140, []), + sectionWithInactiveRoles(template, "verse-2", "verse", 160, [ + "observer", + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(template, "chorus-2", "chorus", 180, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(template, "bridge-2", "bridge", 200, ["keys-right"]), + sectionWithInactiveRoles(template, "outro-2", "outro", 220, []), + sectionWithInactiveRoles(template, "tag-2", "tag", 240, ["observer", "bass-guitar"]), + sectionWithInactiveRoles(template, "stop-2", "stop", 260, ["observer"]), + sectionWithInactiveRoles(template, "ending-2", "outro", 280, ["lead-vocal"]), + sectionWithInactiveRoles(template, "fine-2", "outro", 300, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "observer")).toEqual({ + sectionLabel: "outro", + remainingLastReturnSectionLabel: "outro", + remainingSectionLabel: "stop", + dropoutSectionLabel: "tag", + lastReturnSectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + remainingRoleId: "observer", + remainingRoleName: "Observer" + }); + }); + it("does not tell leftover last-dropout remaining last-return without leftover last-dropout remaining last-return tutti to come in together", () => { const seed = createDemoRehearsalSong(); const template = seed.sections[0]!; @@ -79,7 +158,7 @@ describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search" sectionWithInactiveRoles(template, "outro-1", "outro", 60, []), sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["keys-right", "bass-guitar"]), sectionWithInactiveRoles(template, "coda-1", "stop", 100, ["bass-guitar"]), - sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]) + sectionWithInactiveRoles(template, "ending-1", "outro", 120, ["lead-vocal"]) ] }; From ffbab7d8b4fedb8f33a41bf844ed8778d0938a11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:13:52 +0900 Subject: [PATCH 3/8] test(workspace): reject interrupted remaining-leftover sequence --- ...iningLastReturnTutti.selected-role.test.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts index 0c74885bc..f6f14c7b9 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.selected-role.test.ts @@ -87,6 +87,33 @@ describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search" }); }); + it("invalidates the candidate when a new part drops out during remaining-leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = sectionTemplateWithObserver(seed.sections[0]!); + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["observer"]), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "stop-1", "stop", 100, ["bass-guitar"]), + sectionWithInactiveRoles(template, "ending-1", "ending", 120, ["lead-vocal"]), + sectionWithInactiveRoles(template, "fine-1", "fine", 140, []) + ] + }; + + expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "lead-vocal")).toBeNull(); + }); + it("keeps searching after an earlier completed sequence excludes the selected part", () => { const seed = createDemoRehearsalSong(); const template = sectionTemplateWithObserver(seed.sections[0]!); @@ -164,4 +191,4 @@ describe("firstLeftoverLastDropoutRemainingLastReturnTutti selected-role search" expect(firstLeftoverLastDropoutRemainingLastReturnTutti(song, "bass-guitar")).toBeNull(); }); -}); +}); \ No newline at end of file From 8794fa6cc753b182c550bc25dc3d1187d2ec29a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:16:23 +0900 Subject: [PATCH 4/8] fix(workspace): restart interrupted tutti candidates --- ...overLastDropoutRemainingLastReturnTutti.ts | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts index 25cf2670b..36599c193 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastDropoutRemainingLastReturnTutti.ts @@ -305,22 +305,30 @@ export function firstLeftoverLastDropoutRemainingLastReturnTutti( if (pendingTutti) { const pending = pendingTutti; if (activeRole && !selectedPartBelongs(pending, activeRole)) { + pendingTutti = null; + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } else { + if (sittingOut.length === 0) { + return { + sectionLabel, + remainingLastReturnSectionLabel: pending.remainingLastReturnSectionLabel, + remainingSectionLabel: pending.remainingDropoutSectionLabel, + dropoutSectionLabel: pending.dropoutSectionLabel, + lastReturnSectionLabel: pending.lastReturnSectionLabel, + leftoverSectionLabel: pending.leftoverSectionLabel, + fromSectionLabel: pending.fromSectionLabel, + remainingRoleId: pending.lastRoleId, + remainingRoleName: namedRoles.get(pending.lastRoleId)! + }; + } continue; } - if (sittingOut.length === 0) { - return { - sectionLabel, - remainingLastReturnSectionLabel: pending.remainingLastReturnSectionLabel, - remainingSectionLabel: pending.remainingDropoutSectionLabel, - dropoutSectionLabel: pending.dropoutSectionLabel, - lastReturnSectionLabel: pending.lastReturnSectionLabel, - leftoverSectionLabel: pending.leftoverSectionLabel, - fromSectionLabel: pending.fromSectionLabel, - remainingRoleId: pending.lastRoleId, - remainingRoleName: namedRoles.get(pending.lastRoleId)! - }; - } - continue; } if (pendingRemainingDropout) { @@ -471,6 +479,14 @@ export function firstLeftoverLastDropoutRemainingLastReturnTutti( } if (pendingRemaining) { + const originalSitOutIds = new Set(pendingRemaining.originalSitOutIds); + if (sittingOut.some((node) => !originalSitOutIds.has(node.roleId))) { + pendingRemaining = null; + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } + const remainingNodes: NamedGraphNode[] = []; for (const remainingId of pendingRemaining.remainingIds) { const remainingNode = nodes.find((node) => node.roleId === remainingId); @@ -596,4 +612,4 @@ export function firstLeftoverLastDropoutRemainingLastReturnTutti( } return null; -} +} \ No newline at end of file From 885279e2851dd36c002ef43888b82866a0a8808c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 08:20:15 +0900 Subject: [PATCH 5/8] test(workspace): require truthful missing-cue guidance --- apps/desktop/src/features/workspace/Workspace.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 0e275cdb6..7a22387f2 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -362,14 +362,14 @@ describe("Workspace", () => { ); }); - it("asks the player to confirm leftover last-dropout remaining last-return tutti when every part stays active", () => { + it("asks the player to verify the required not-yet-all-in then all-in order", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); render(); expect(screen.getByTestId("first-leftover-last-dropout-remaining-last-return-tutti")).toHaveTextContent( - "Tonight's first leftover last-dropout remaining last-return tutti still needs leftover last-dropout remaining last-return that is not already all-in, then a later all-in. Confirm leftover last-dropout remaining last-return is all-in before the first section." + "This cue needs a last-return section where someone is still out, followed by a later section where everyone is in. Before rehearsing, confirm that order in the map." ); }); From b4fb5a87abca9e0a1779f5e73318f84d8bc96934 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:15:11 +0900 Subject: [PATCH 6/8] fix(workspace): correct missing tutti guidance --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 8f97315b9..632c37f9c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -156,7 +156,7 @@ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "Tonight's first leftover last-dropout remaining last-return tutti", "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Count the leftover last-dropout remaining last-return tutti in from the top of {sectionLabel}.", "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName} comes back at {remainingLastReturnSectionLabel} after leftover last-dropout remaining at {remainingSectionLabel}, and the band is all in at {sectionLabel}. Come in together from the top of {sectionLabel}.", - "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "Tonight's first leftover last-dropout remaining last-return tutti still needs leftover last-dropout remaining last-return that is not already all-in, then a later all-in. Confirm leftover last-dropout remaining last-return is all-in before the first section.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "This cue needs a last-return section where someone is still out, followed by a later section where everyone is in. Before rehearsing, confirm that order in the map.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } From 34e9fb3097e8b2c76ee6809362eb2abbd61690e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:15:58 +0900 Subject: [PATCH 7/8] fix(workspace): align Korean missing tutti guidance --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 91352d009..fdafd37d9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -156,7 +156,7 @@ "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiTitle": "오늘 먼저 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 후 투티", "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiNamed": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 투티를 넣으세요.", "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiComeIn": "{remainingRoleName}은 {remainingSectionLabel}에서 마지막 복귀 후 쉬는 자리 중 남는 자리로 쉰 뒤 {remainingLastReturnSectionLabel}에서 마지막으로 들어오고, {sectionLabel}에서 전원이 모입니다. {sectionLabel} 처음부터 같이 들어오세요.", - "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "오늘 먼저 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 투티는 아직 확인이 필요합니다. 마지막 복귀 후 쉬는 자리 중 남는 자리의 마지막 복귀 뒤 전원이 모이는지 첫 구간 전에 확인하세요.", + "workspaceFirstLeftoverLastDropoutRemainingLastReturnTuttiMissing": "이 큐를 잡으려면 마지막 복귀 시점에도 한 파트가 쉬고 있어야 하고, 그 뒤 구간에서 전원이 들어와야 합니다. 합주 전에 지도에서 이 순서를 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From 19ae5e65468137c8c0f2bb5d8335e8ff6d3a821f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:16:28 +0900 Subject: [PATCH 8/8] docs(workspace): align tutti transition contract --- ...eftover-last-dropout-remaining-last-return-tutti.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md index ce8bacb79..4d37f889b 100644 --- a/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md +++ b/docs/doctoring/first-leftover-last-dropout-remaining-last-return-tutti.md @@ -1,18 +1,18 @@ # Tonight's first leftover last-dropout remaining last-return tutti -The ready rehearsal map names the first leftover last-dropout remaining last-return tutti from existing `partGraph` evidence: a named leftover sit-out, then a later named leftover return where at least one leftover named part is own-property active and at least one leftover remains own-property tacet, then a later named leftover last-return where every remaining leftover is own-property active, then a later named leftover last-dropout where at least two named parts are own-property tacet, then a later named leftover last-dropout remaining where at least one leftover last-dropout named part is own-property active and at least one leftover last-dropout remains own-property tacet, then a later named leftover last-dropout remaining last-return where every leftover last-dropout remaining named part is own-property active and at least one named part remains own-property tacet, then a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. This is when the band is all in after leftover last-dropout remaining last-return. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover at leftover return, leftover last-return, leftover last-dropout, leftover last-dropout remaining, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout return with nobody still out, tutti, handoff, Fine, last-line breath, a singleton leftover last-dropout, leftover last-dropout remaining last-return without a later tutti, a new dropout after remaining leftover, or a new MIR product. +The ready rehearsal map names the first leftover last-dropout remaining last-return tutti from existing `partGraph` evidence: a named leftover sit-out, then a later named leftover return where at least one leftover named part is own-property active and at least one leftover remains own-property tacet, then a later named leftover last-return where every remaining leftover is own-property active, then a later named leftover last-dropout where at least two named parts are own-property tacet, then a later named leftover last-dropout remaining where at least one leftover last-dropout named part is own-property active and at least one leftover last-dropout remains own-property tacet, then a later named leftover last-dropout remaining last-return where every leftover last-dropout remaining named part is own-property active and at least one named part remains own-property tacet, then a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. This is when the band is all in after leftover last-dropout remaining last-return. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover at leftover return, leftover last-return, leftover last-dropout, leftover last-dropout remaining, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout return with nobody still out, tutti, handoff, Fine, last-line breath, a singleton leftover last-dropout, leftover last-dropout remaining last-return without a later tutti, a newly inactive role outside the original reduction cohort while a remaining return is being tracked, or a new MIR product. ## Next action - Named leftover last-dropout remaining last-return tutti: come in together from the top of the named leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return. - Named returning or other included part: count the leftover last-dropout remaining last-return tutti in from the top of that tutti. -- Missing leftover last-dropout remaining last-return tutti: confirm leftover last-dropout remaining last-return is all-in before the first section. +- Missing leftover last-dropout remaining last-return tutti: confirm a last-return section where someone is still out, then confirm a later section where everyone is in before rehearsal. ## Security Notes - Untrusted input: `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 an own-property leftover sit-out, leftover return with remaining leftover, leftover last-return, leftover last-dropout cohort of at least two named parts, leftover last-dropout remaining, leftover last-dropout remaining last-return that is not already all-in, and a later named leftover last-dropout remaining last-return tutti where every named graph role is own-property active. -- Allowlist: section labels and role names must be meaningful text. A missing graph node is not leftover last-dropout remaining last-return tutti. Inherited `is_active` is isolated. Leftover last-dropout remaining last-return that is already all-in is leftover last-dropout remaining last-return, not leftover last-dropout remaining last-return tutti. Leftover last-dropout remaining is leftover last-dropout remaining, not leftover last-dropout remaining last-return tutti. A leftover last-dropout return with nobody still out is leftover last-dropout return, not leftover last-dropout remaining last-return tutti. A leftover last-return is leftover last-return, not leftover last-dropout remaining last-return tutti. Remaining leftover at leftover return is remaining leftover, not leftover last-dropout remaining last-return tutti. A singleton leftover last-dropout cannot leave leftover last-dropout remaining last-return tutti. A new dropout after remaining leftover is a dropout, not leftover last-dropout remaining last-return tutti. All-active later sections after leftover last-return are tuttis, not leftover last-dropout remaining last-return tutti, until leftover last-dropout remaining last-return exists and later tuttis. When a role is selected, only leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return that includes that named part, leftover at leftover last-dropout remaining last-return of that named part, or leftover last-dropout remaining last-return tutti of that named part, is shown. -- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without leftover return, leftover last-dropouts without leftover last-dropout remaining, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns with nobody still out, singleton leftover last-dropouts, come-ins without a leftover, full-band returns, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing leftover last-dropout remaining last-return tutti. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not leftover last-dropout remaining last-return tutti. Inherited `is_active` is isolated. Leftover last-dropout remaining last-return that is already all-in is leftover last-dropout remaining last-return, not leftover last-dropout remaining last-return tutti. Leftover last-dropout remaining is leftover last-dropout remaining, not leftover last-dropout remaining last-return tutti. A leftover last-dropout return with nobody still out is leftover last-dropout return, not leftover last-dropout remaining last-return tutti. A leftover last-return is leftover last-return, not leftover last-dropout remaining last-return tutti. Remaining leftover at leftover return is remaining leftover, not leftover last-dropout remaining last-return tutti. A singleton leftover last-dropout cannot leave leftover last-dropout remaining last-return tutti. While a remaining return is being tracked, newly inactive roles outside the original reduction cohort invalidate and reseed the candidate. A role from the original reduction cohort may become inactive again and later return; that transition alone does not invalidate the candidate. All-active later sections after leftover last-return are tuttis, not leftover last-dropout remaining last-return tutti, until leftover last-dropout remaining last-return exists and later tuttis. When a role is selected, only leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return that includes that named part, leftover at leftover last-dropout remaining last-return of that named part, or leftover last-dropout remaining last-return tutti of that named part, is shown. +- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without leftover return, leftover last-dropouts without leftover last-dropout remaining, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns with nobody still out, singleton leftover last-dropouts, come-ins without a leftover, full-band returns, remaining leftovers without last-return, newly inactive roles outside the original reduction cohort while a remaining return is being tracked, unnamed roles, empty graphs, and malformed roots return `null` or cause the current candidate to be discarded and search to continue, so the workspace does not invent a leftover last-dropout remaining last-return tutti. - Logging/privacy: rejected or accepted leftover last-dropout remaining last-return tutti cues are not logged. Copy interpolation keeps rehearsal values literal. -- Tests: `firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts`, selected-role search, and the Workspace callout cover the demo all-active case, an explicit bass leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return, selected-role scoping, inherited flags, missing `is_active`, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns, singleton leftover last-dropouts, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and literal copy filling. +- Tests: `firstLeftoverLastDropoutRemainingLastReturnTutti.test.ts`, selected-role search, and the Workspace callout cover the demo all-active case, an explicit bass leftover last-dropout remaining last-return tutti after leftover last-dropout remaining last-return, selected-role scoping, inherited flags, missing `is_active`, leftover last-dropout remaining last-return without later tutti, leftover last-dropout remaining last-return that is already all-in, leftover last-dropout returns, singleton leftover last-dropouts, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, an original-cohort selected role becoming inactive again and later returning, a new outside-cohort dropout invalidating the candidate, unnamed roles, empty graphs, and literal copy filling.