diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..c824f226c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and the first come-in so a sitting-out part knows where to return. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..d77564148 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and the first come-in so a sitting-out part knows where to return - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..81e6dc749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. +- Name tonight's first come-in on the ready rehearsal map and tell the player to play from the top of that section after sitting out. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..34e826ade 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, the next instrument check, and the first come-in so a sitting-out part knows where to return. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..0eedc7854 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,72 @@ describe("Workspace", () => { ); }); + it("names tonight's first come-in and the next return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 } + } + ]; + + render(); + + const callout = screen.getByTestId("first-come-in"); + expect(callout).toHaveTextContent("Tonight's first come-in"); + expect(callout).toHaveTextContent( + "Keyboard 1 Right Hand comes in on chorus. Play from the top after sitting out of verse." + ); + }); + + it("keeps the selected active part on tonight's first come-in", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 } + } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-come-in")).toHaveTextContent( + "Lead Vocal comes in on chorus. Play from the top after sitting out of verse." + ); + }); + + it("asks the player to confirm the return when every part stays active", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-come-in")).toHaveTextContent( + "Tonight's first come-in still needs a return. Confirm where the sitting-out part comes back before the first section." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..29c6495bf 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { firstComeIn } from "./firstComeIn"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const namedComeIn = useMemo(() => firstComeIn(song, activeRole), [activeRole, song]); + const firstComeInCopy = namedComeIn + ? fillRangeCopy(t("workspaceFirstComeInNamed"), { + roleName: namedComeIn.roleName, + sectionLabel: namedComeIn.sectionLabel, + fromSectionLabel: namedComeIn.fromSectionLabel + }) + : t("workspaceFirstComeInMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +318,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstComeInTitle")}

+

{firstComeInCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts b/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts new file mode 100644 index 000000000..0178ac3c4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstComeIn.conflict.test.ts @@ -0,0 +1,37 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstComeIn } from "./firstComeIn"; + +function songWithSameSectionConflict(reverse: boolean): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const bass = template.partGraph.find((node) => node.role_id === "bass-guitar")!; + const others = template.partGraph.filter((node) => node.role_id !== "bass-guitar"); + const inactiveBass = { ...bass, is_active: false }; + const activeBass = { ...bass, is_active: true }; + const conflict = { + ...template, + id: "verse-conflict", + label: "verse" as RehearsalSong["sections"][number]["label"], + timeRange: { start: 0, end: 20 }, + partGraph: [ + ...others, + ...(reverse ? [activeBass, inactiveBass] : [inactiveBass, activeBass]) + ] + }; + const later = { + ...template, + id: "verse-later", + label: "verse" as RehearsalSong["sections"][number]["label"], + timeRange: { start: 20, end: 40 } + }; + return { ...seed, sections: [conflict, later] }; +} + +describe("firstComeIn conflicting section evidence", () => { + it("fails closed for false/true and true/false duplicates before a later active section", () => { + for (const reverse of [false, true]) { + expect(firstComeIn(songWithSameSectionConflict(reverse), "bass-guitar")).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/features/workspace/firstComeIn.test.ts b/apps/desktop/src/features/workspace/firstComeIn.test.ts new file mode 100644 index 000000000..7eed56e37 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstComeIn.test.ts @@ -0,0 +1,194 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstComeIn } from "./firstComeIn"; + +function withChorus(song: RehearsalSong): RehearsalSong { + const verse = song.sections[0]!; + const chorus = { + ...verse, + id: "chorus-1", + label: "chorus" as RehearsalSong["sections"][number]["label"], + timeRange: { + start: verse.timeRange.end, + end: verse.timeRange.end + 20 + } + }; + return { ...song, sections: [verse, chorus] }; +} + +function withSitOutThenComeIn( + song: RehearsalSong, + roleId: string, + sitOutActive: boolean | "omit" = false +): RehearsalSong { + const twoSection = withChorus(song); + return { + ...twoSection, + sections: twoSection.sections.map((section, index) => + index === 0 + ? { + ...section, + partGraph: section.partGraph.map((node) => { + if (node.role_id !== roleId) { + return node; + } + if (sitOutActive === "omit") { + const rest: Record = { + role_id: node.role_id, + handoff_to: node.handoff_to, + handoff_from: node.handoff_from + }; + return rest as RehearsalSong["sections"][number]["partGraph"][number]; + } + return { ...node, is_active: sitOutActive }; + }) + } + : section + ) + }; +} + +describe("firstComeIn", () => { + it("returns null on the demo song where every graph node is active", () => { + expect(firstComeIn(createDemoRehearsalSong())).toBeNull(); + expect(firstComeIn(withChorus(createDemoRehearsalSong()))).toBeNull(); + }); + + it("names the first explicit return from existing part-graph evidence", () => { + expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"))).toEqual({ + sectionLabel: "chorus", + roleName: "Keyboard 1 Right Hand", + fromSectionLabel: "verse" + }); + }); + + it("keeps the first return when a later section repeats the sit-out label", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"); + const repeatedVerse = { + ...song.sections[1]!, + id: "verse-2", + label: "verse" as RehearsalSong["sections"][number]["label"] + }; + const laterChorus = { + ...song.sections[1]!, + id: "chorus-2", + timeRange: { + start: song.sections[1]!.timeRange.end, + end: song.sections[1]!.timeRange.end + 20 + } + }; + song.sections = [song.sections[0]!, repeatedVerse, laterChorus]; + + expect(firstComeIn(song)).toEqual({ + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand", + fromSectionLabel: "verse" + }); + }); + + it("skips blank come-in labels until a named return exists", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"); + song.sections[1] = { + ...song.sections[1]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(firstComeIn(song)).toBeNull(); + }); + + it("keeps the selected active part on tonight's first come-in", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "lead-vocal"); + expect(firstComeIn(song, "lead-vocal")).toEqual({ + sectionLabel: "chorus", + roleName: "Lead Vocal", + fromSectionLabel: "verse" + }); + expect(firstComeIn(song, "bass-guitar")).toBeNull(); + }); + + it("ignores inherited is_active evidence", () => { + const song = withChorus(createDemoRehearsalSong()); + const inherited = Object.create({ + is_active: false, + role_id: "keys-right" + }) as RehearsalSong["sections"][number]["partGraph"][number]; + song.sections[0] = { + ...song.sections[0]!, + partGraph: [inherited, ...song.sections[0]!.partGraph] + }; + expect(firstComeIn(song)).toBeNull(); + }); + + it("does not treat a missing is_active flag as a sit-out", () => { + expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right", "omit"))).toBeNull(); + }); + + it("does not treat the song's opening entrance as a come-in", () => { + expect(firstComeIn(withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right", true))).toBeNull(); + }); + + it("fails closed when the return section has no named role", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"); + song.sections[1] = { + ...song.sections[1]!, + roles: song.sections[1]!.roles.map((role) => + role.id === "keys-right" ? { ...role, name: " " } : role + ) + }; + expect(firstComeIn(song)).toBeNull(); + }); + + it("rejects inherited return-section roles as naming authority", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"); + const returnSection = song.sections[1]!; + const returnWithoutOwnRoles = { ...returnSection } as Record; + delete returnWithoutOwnRoles.roles; + song.sections[1] = Object.assign( + Object.create({ roles: returnSection.roles }), + returnWithoutOwnRoles + ) as RehearsalSong["sections"][number]; + + expect(firstComeIn(song)).toBeNull(); + }); + + it("fails closed when the first active return has no trustworthy role name", () => { + const song = withSitOutThenComeIn(createDemoRehearsalSong(), "keys-right"); + const chorus = song.sections[1]!; + const unnamedChorus = { + ...chorus, + roles: chorus.roles.map((role) => + role.id === "keys-right" ? { ...role, name: " " } : role + ) + }; + const bridge = { + ...chorus, + id: "bridge-1", + label: "bridge" as RehearsalSong["sections"][number]["label"], + timeRange: { + start: chorus.timeRange.end, + end: chorus.timeRange.end + 20 + } + }; + song.sections = [song.sections[0]!, unnamedChorus, bridge]; + + expect(firstComeIn(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect(firstComeIn(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); +}); + +describe("come-in copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy("{roleName} comes in on {sectionLabel} after {fromSectionLabel}.", { + roleName: "Bass {sectionLabel}", + sectionLabel: "chorus", + fromSectionLabel: "verse" + }) + ).toBe("Bass {sectionLabel} comes in on chorus after verse."); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstComeIn.ts b/apps/desktop/src/features/workspace/firstComeIn.ts new file mode 100644 index 000000000..600cd83fd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstComeIn.ts @@ -0,0 +1,151 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named return after an explicit sit-out. */ +export type FirstComeIn = { + sectionLabel: string; + roleName: string; + fromSectionLabel: string; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */ +function ownActiveFlag(value: Record): boolean | null { + if (!Object.prototype.hasOwnProperty.call(value, "is_active")) { + return null; + } + if (value.is_active === true) { + return true; + } + if (value.is_active === false) { + return false; + } + return null; +} + +/** + * Resolve a named role from a section's own-property `roles` list. + * + * Blank, `none`, inherited, or non-string names are not rehearsal authority. + */ +function namedRoleOnSection( + sectionValue: Record, + roleId: string +): string | undefined { + if ( + !Object.prototype.hasOwnProperty.call(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return undefined; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { + continue; + } + if (meaningfulRangeText(roleValue.id) !== roleId) { + continue; + } + return meaningfulRangeText( + Object.prototype.hasOwnProperty.call(roleValue, "name") ? roleValue.name : undefined + ); + } + return undefined; +} + +type SitOutEvidence = { + sectionIndex: number; + sectionLabel: string; +}; + +/** + * Pick the first explicit return a player should take after sitting out. + * + * Uses existing `partGraph` `is_active` authority already produced by + * analysis. A come-in is the first later named section where a part that + * sat out (`is_active: false`) is own-property active again. Section identity + * is tracked by iteration index so repeated form labels remain valid later + * returns while false/true evidence inside one section is rejected. Inherited + * `is_active`, inherited role registries, missing graph nodes, blank labels, + * unnamed roles, and malformed roots fail closed. When a role is selected, + * only that part's return is named. + */ +export function firstComeIn( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstComeIn | null { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return null; + } + + const sittingOut = new Map(); + + for (const [sectionIndex, sectionValue] of song.sections.entries()) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + if (!Array.isArray(sectionValue.partGraph)) { + continue; + } + + const sectionActivity = new Map(); + for (const nodeValue of sectionValue.partGraph) { + if ( + !isRuntimeObject(nodeValue) || + !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") + ) { + continue; + } + const roleId = meaningfulRangeText(nodeValue.role_id); + if (!roleId) { + continue; + } + if (activeRole && roleId !== activeRole) { + continue; + } + + const activeFlag = ownActiveFlag(nodeValue); + if (activeFlag === null) { + continue; + } + const existingFlag = sectionActivity.get(roleId); + if (existingFlag !== undefined && existingFlag !== activeFlag) { + return null; + } + if (existingFlag === undefined) { + sectionActivity.set(roleId, activeFlag); + } + } + + for (const [roleId, activeFlag] of sectionActivity) { + if (activeFlag === false) { + if (!sittingOut.has(roleId)) { + sittingOut.set(roleId, { sectionIndex, sectionLabel }); + } + continue; + } + + const sitOut = sittingOut.get(roleId); + if (!sitOut || sitOut.sectionIndex === sectionIndex) { + continue; + } + + const roleName = namedRoleOnSection(sectionValue, roleId); + if (!roleName) { + sittingOut.delete(roleId); + continue; + } + + return { sectionLabel, roleName, fromSectionLabel: sitOut.sectionLabel }; + } + } + + return null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..79d67b465 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -60,6 +60,7 @@ describe("i18n", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("workspaceFirstComeInTitle")).toBe("오늘 먼저 들어올 자리"); }); it("falls back to English when a Korean translation is missing", () => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..d91a723ea 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,9 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstComeInTitle": "Tonight's first come-in", + "workspaceFirstComeInNamed": "{roleName} comes in on {sectionLabel}. Play from the top after sitting out of {fromSectionLabel}.", + "workspaceFirstComeInMissing": "Tonight's first come-in still needs a return. Confirm where the sitting-out part comes back before the first section.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5d6866f0a 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,9 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstComeInTitle": "오늘 먼저 들어올 자리", + "workspaceFirstComeInNamed": "{roleName}은 {fromSectionLabel}에서 쉰 뒤 {sectionLabel}부터 들어옵니다. 그 구간 처음부터 연주하세요.", + "workspaceFirstComeInMissing": "오늘 먼저 들어올 자리는 아직 없습니다. 첫 구간 전에 쉬는 파트가 어디서 다시 들어오는지 확인해 보세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..d49c275b4 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. - `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy. - `EmptyState` must remain an actionable state card, not a blank placeholder panel. +- Ready `Workspace` names tonight's first playable range and tonight's first come-in so the map enables the next rehearsal action without opening files or export paths. - If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. ## Pattern Backlog diff --git a/docs/doctoring/first-come-in.md b/docs/doctoring/first-come-in.md new file mode 100644 index 000000000..355ed2bbc --- /dev/null +++ b/docs/doctoring/first-come-in.md @@ -0,0 +1,17 @@ +# Tonight's first come-in + +The ready rehearsal map names the first explicit return from existing `partGraph` evidence: a part that sat out (`is_active: false`) on a named section and is own-property active again on a later named section. This is where the player should come back in so the band does not miss the entrance after a rest. It is not a tacet, dropout, handoff, Fine, last-line breath, or the song's opening entrance. + +## Next action + +- Named: play from the top of the named section after sitting out of the earlier section. +- Missing: confirm where the sitting-out part comes back before the first section. + +## Security Notes + +- Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project. +- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property `is_active: false` followed by a later own-property `is_active: true` on a named section. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a come-in. Inherited `is_active` is isolated. Same-section false-then-true nodes are not a return. When a role is selected, only that part's own-property return is named. +- Safe failure: inherited flags, blank labels, missing names, opening entrances without a prior sit-out, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a return. +- Logging/privacy: rejected or accepted come-ins are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstComeIn.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys return on chorus, selected-role scoping, inherited flags, missing `is_active`, opening-entrance rejection, unnamed roles, and literal copy filling.