From 72b04a0248e52013c4c8f336a04b7b39ea199f62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 17:26:28 +0000 Subject: [PATCH 01/13] feat(workspace): name tonight's first score check on the map Tell the player to open a trusted attached score in Score and check tonight's first range on the page, or add a score and check the span by ear. Display names stay fail-closed; PDF bytes stay on the Score bridge. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 61 +++++++ .../src/features/workspace/Workspace.tsx | 26 +++ .../workspace/firstScoreCheck.test.ts | 168 ++++++++++++++++++ .../src/features/workspace/firstScoreCheck.ts | 129 ++++++++++++++ apps/desktop/src/i18n/index.test.ts | 1 + apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + docs/design-system/component-contract.md | 1 + 12 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstScoreCheck.test.ts create mode 100644 apps/desktop/src/features/workspace/firstScoreCheck.ts diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..50390359d 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 attached score to open in Score. - 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..ec1afa31c 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 attached score to open in Score - 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..c4f2e6bca 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 attached score on the ready rehearsal map and tell the player to open it in Score, or add a score and check the first range by ear. - 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..cddaae75f 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 score to open in Score. `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..48527984d 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,67 @@ describe("Workspace", () => { ); }); + it("names tonight's first score check and the next Score action", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.scoreAttachments = [ + { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } + ]; + + render(); + + const callout = screen.getByTestId("first-score-check"); + expect(callout).toHaveTextContent("Tonight's first score check"); + expect(callout).toHaveTextContent( + "Open opener.pdf in Score and check Bass Guitar's C#2–E3 in verse against the page before the verse." + ); + }); + + it("asks the player to add a score when no trusted attachment exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-score-check")).toHaveTextContent( + "Add a score in Score, or check tonight's first range by ear before the first section." + ); + }); + + it("asks the player to mark the range on the page when the score is attached without a span", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.scoreAttachments = [ + { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } + ]; + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + + render(); + + expect(screen.getByTestId("first-score-check")).toHaveTextContent( + "Open opener.pdf in Score and mark tonight's first range on the page." + ); + }); + + it("limits the score-check range to the selected role", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.scoreAttachments = [ + { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-score-check")).toHaveTextContent( + "Open opener.pdf in Score and check Lead Vocal's G#3–C#5 in verse against the page before the verse." + ); + }); + 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..cbae4e5c7 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 { firstScoreCheck } from "./firstScoreCheck"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,23 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstScore = useMemo(() => firstScoreCheck(song, activeRole), [activeRole, song]); + const firstScoreCopy = firstScore + ? fillRangeCopy( + t( + firstScore.sectionLabel + ? "workspaceFirstScoreCheck" + : "workspaceFirstScoreCheckNoRange" + ), + { + fileName: firstScore.fileName, + roleName: firstScore.roleName ?? "", + lowestNote: firstScore.lowestNote ?? "", + highestNote: firstScore.highestNote ?? "", + sectionLabel: firstScore.sectionLabel ?? "" + } + ) + : t("workspaceFirstScoreMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +327,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstScoreTitle")}

+

{firstScoreCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts new file mode 100644 index 000000000..2781c8a98 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts @@ -0,0 +1,168 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + MAX_SCORE_DISPLAY_NAME_LENGTH, + firstScoreCheck, + trustedScoreAttachment, + trustedScoreFileName +} from "./firstScoreCheck"; +import { fillRangeCopy } from "./firstRangeSqueeze"; + +const TRUSTED_SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455"; + +function songWithAttachments( + attachments: unknown, + song: RehearsalSong = createDemoRehearsalSong() +): RehearsalSong { + return { + ...song, + scoreAttachments: attachments as RehearsalSong["scoreAttachments"] + }; +} + +function blankRoleRange(song: RehearsalSong): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "" }, + overlapWarnings: [] + })) + })) + }; +} + +describe("trustedScoreFileName", () => { + it("admits a bounded PDF basename", () => { + expect(trustedScoreFileName("opener.pdf")).toBe("opener.pdf"); + expect(trustedScoreFileName("Late Night Set.PDF")).toBe("Late Night Set.PDF"); + }); + + it("fails closed on blank, overlong, path, control, and reserved names", () => { + for (const value of [ + "", + "pdf", + ".pdf", + " opener.pdf", + "opener.pdf ", + "opener.pdf/", + "../opener.pdf", + "folder/opener.pdf", + "folder\\opener.pdf", + "open\ner.pdf", + "open\u0000er.pdf", + "CON.pdf", + "aux.PDF", + "opener.docx", + `${"a".repeat(MAX_SCORE_DISPLAY_NAME_LENGTH)}.pdf` + ]) { + expect(trustedScoreFileName(value)).toBeNull(); + } + }); +}); + +describe("trustedScoreAttachment", () => { + it("admits a lowercase UUID plus trusted PDF basename", () => { + expect( + trustedScoreAttachment({ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }) + ).toEqual({ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }); + }); + + it("fails closed on extra keys, inherited members, and malformed ids", () => { + expect(trustedScoreAttachment(null)).toBeNull(); + expect(trustedScoreAttachment({ fileName: "opener.pdf" })).toBeNull(); + expect( + trustedScoreAttachment({ + id: TRUSTED_SCORE_ID, + fileName: "opener.pdf", + extra: true + }) + ).toBeNull(); + expect( + trustedScoreAttachment({ + id: "3F2C8F0E-1A2B-4C3D-8E9F-001122334455", + fileName: "opener.pdf" + }) + ).toBeNull(); + expect( + trustedScoreAttachment({ + id: "../../etc/passwd-aaaa-bbbb-cccc-dddddddddddd", + fileName: "opener.pdf" + }) + ).toBeNull(); + expect( + trustedScoreAttachment(Object.create({ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" })) + ).toBeNull(); + }); +}); + +describe("firstScoreCheck", () => { + it("returns null when no trusted score is attached", () => { + expect(firstScoreCheck(createDemoRehearsalSong())).toBeNull(); + expect(firstScoreCheck(songWithAttachments(null))).toBeNull(); + expect(firstScoreCheck(songWithAttachments([]))).toBeNull(); + expect(firstScoreCheck(songWithAttachments([{ id: "bad", fileName: "opener.pdf" }]))).toBeNull(); + }); + + it("skips malformed attachments and names the first trusted score with tonight's range", () => { + const check = firstScoreCheck( + songWithAttachments([ + { id: "bad", fileName: "skip.pdf" }, + { id: TRUSTED_SCORE_ID, fileName: "opener.pdf" } + ]) + ); + + expect(check).toEqual({ + fileName: "opener.pdf", + sectionLabel: "verse", + roleName: "Bass Guitar", + lowestNote: "C#2", + highestNote: "E3" + }); + }); + + it("still names the score when the selected part has no playable span", () => { + expect( + firstScoreCheck( + songWithAttachments( + [{ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }], + blankRoleRange(createDemoRehearsalSong()) + ) + ) + ).toEqual({ fileName: "opener.pdf" }); + }); + + it("limits the paired range to the selected role", () => { + expect( + firstScoreCheck( + songWithAttachments([{ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }]), + "lead-vocal" + ) + ).toEqual({ + fileName: "opener.pdf", + sectionLabel: "verse", + roleName: "Lead Vocal", + lowestNote: "G#3", + highestNote: "C#5" + }); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { scoreAttachments: {} }]) { + expect(firstScoreCheck(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); +}); + +describe("score check copy filling", () => { + it("keeps the attached file name literal", () => { + expect( + fillRangeCopy("Open {fileName} in Score before the {sectionLabel}.", { + fileName: "Bass $& {sectionLabel}.pdf", + sectionLabel: "verse" + }) + ).toBe("Open Bass $& {sectionLabel}.pdf in Score before the verse."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.ts b/apps/desktop/src/features/workspace/firstScoreCheck.ts new file mode 100644 index 000000000..8edddb333 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstScoreCheck.ts @@ -0,0 +1,129 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { firstRangeSqueeze } from "./firstRangeSqueeze"; + +export /** Inclusive maximum length for a rehearsal-usable score display name. */ const MAX_SCORE_DISPLAY_NAME_LENGTH = 80; + +/** Tonight's first trusted attached score, optionally paired with the first range. */ +export type FirstScoreCheck = { + fileName: string; + sectionLabel?: string; + roleName?: string; + lowestNote?: string; + highestNote?: string; +}; + +const SCORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; +const RESERVED_SCORE_STEM = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu; + +/** Return whether a display name contains a forbidden path or control character. */ +function hasForbiddenScoreNameChar(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x1f || code === 0x7f || character === "/" || character === "\\") { + return true; + } + } + return false; +} + +/** 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 only a display basename for an attached score PDF. + * + * Path separators, control characters, reserved Win32 device stems, parent + * traversal, leading or trailing whitespace, and overlong names fail closed. + * This is display copy only: it is not a filesystem path, PDF parser input, + * or native score-storage authority. + */ +export function trustedScoreFileName(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + if (value.length < 5 || value.length > MAX_SCORE_DISPLAY_NAME_LENGTH) { + return null; + } + if (value !== value.trim() || hasForbiddenScoreNameChar(value) || value.includes("..")) { + return null; + } + if (!/\.pdf$/iu.test(value)) { + return null; + } + const stem = value.slice(0, -4); + if (!stem || stem.endsWith(".") || stem.endsWith(" ") || RESERVED_SCORE_STEM.test(stem)) { + return null; + } + return value; +} + +/** + * Admit a score attachment only when its id matches the native UUID allowlist + * and its file name is a trusted display basename. Extra keys fail closed. + */ +export function trustedScoreAttachment( + value: unknown +): { id: string; fileName: string } | null { + if (!isRuntimeObject(value)) { + return null; + } + const keys = Object.keys(value); + if (keys.length !== 2) { + return null; + } + if ( + !Object.prototype.hasOwnProperty.call(value, "id") || + !Object.prototype.hasOwnProperty.call(value, "fileName") + ) { + return null; + } + if (typeof value.id !== "string" || !SCORE_ID_PATTERN.test(value.id)) { + return null; + } + const fileName = trustedScoreFileName(value.fileName); + if (fileName === null) { + return null; + } + return { id: value.id, fileName }; +} + +/** + * Pick the first trusted attached score a player should open in Score. + * + * Skips malformed collection members instead of treating them as authority. + * When a playable range also exists, pair it so the map names the notes to + * check on the page. Runtime roots are untrusted; this never opens, reads, + * or parses PDF bytes. + */ +export function firstScoreCheck( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstScoreCheck | null { + if (!isRuntimeObject(song) || !Array.isArray(song.scoreAttachments)) { + return null; + } + + for (const attachment of song.scoreAttachments) { + const trusted = trustedScoreAttachment(attachment); + if (trusted === null) { + continue; + } + + const range = firstRangeSqueeze(song as RehearsalSong, activeRole); + if (range) { + return { + fileName: trusted.fileName, + sectionLabel: range.sectionLabel, + roleName: range.roleName, + lowestNote: range.lowestNote, + highestNote: range.highestNote + }; + } + + return { fileName: trusted.fileName }; + } + + return null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..fbd43ba1f 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("workspaceFirstScoreTitle")).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..2ccf6241c 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.", + "workspaceFirstScoreTitle": "Tonight's first score check", + "workspaceFirstScoreCheck": "Open {fileName} in Score and check {roleName}'s {lowestNote}–{highestNote} in {sectionLabel} against the page before the {sectionLabel}.", + "workspaceFirstScoreCheckNoRange": "Open {fileName} in Score and mark tonight's first range on the page.", + "workspaceFirstScoreMissing": "Add a score in Score, or check tonight's first range by ear 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..16243853a 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": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstScoreTitle": "오늘 먼저 볼 악보", + "workspaceFirstScoreCheck": "Score에서 {fileName}을 열고 {sectionLabel}의 {roleName} {lowestNote}–{highestNote}를 페이지에서 확인한 다음 {sectionLabel}에 들어가세요.", + "workspaceFirstScoreCheckNoRange": "Score에서 {fileName}을 열고 오늘 먼저 볼 음역을 페이지에 표시하세요.", + "workspaceFirstScoreMissing": "Score에서 악보를 추가하거나, 첫 구간 전에 오늘 먼저 볼 음역을 귀로 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..02ec52db2 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 score check so the map enables the next rehearsal action without opening PDF bytes. - If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. ## Pattern Backlog From 2316aa2720708417114ffcea3209bc946432870a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:07:53 -0700 Subject: [PATCH 02/13] test(workspace): reproduce native-valid score metadata rejection --- .../workspace/firstScoreCheck.test.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts index 2781c8a98..0f77386c0 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts @@ -1,7 +1,6 @@ import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; import { - MAX_SCORE_DISPLAY_NAME_LENGTH, firstScoreCheck, trustedScoreAttachment, trustedScoreFileName @@ -35,28 +34,35 @@ function blankRoleRange(song: RehearsalSong): RehearsalSong { } describe("trustedScoreFileName", () => { - it("admits a bounded PDF basename", () => { + it("admits a PDF basename", () => { expect(trustedScoreFileName("opener.pdf")).toBe("opener.pdf"); expect(trustedScoreFileName("Late Night Set.PDF")).toBe("Late Night Set.PDF"); }); - it("fails closed on blank, overlong, path, control, and reserved names", () => { + it("keeps native-valid display basenames instead of hiding attached scores", () => { + for (const value of [ + " opener.pdf", + "opener.pdf ", + "mix..final.pdf", + "CON.pdf", + `${"a".repeat(120)}.pdf` + ]) { + expect(trustedScoreFileName(value)).toBe(value); + } + }); + + it("fails closed on blank, path, control, and non-PDF names", () => { for (const value of [ "", "pdf", ".pdf", - " opener.pdf", - "opener.pdf ", "opener.pdf/", "../opener.pdf", "folder/opener.pdf", "folder\\opener.pdf", "open\ner.pdf", "open\u0000er.pdf", - "CON.pdf", - "aux.PDF", - "opener.docx", - `${"a".repeat(MAX_SCORE_DISPLAY_NAME_LENGTH)}.pdf` + "opener.docx" ]) { expect(trustedScoreFileName(value)).toBeNull(); } From fd2c3a98fc3740a277f58c851b1045ce92a480d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:09:15 -0700 Subject: [PATCH 03/13] fix(workspace): preserve native-valid score display names --- .../src/features/workspace/firstScoreCheck.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.ts b/apps/desktop/src/features/workspace/firstScoreCheck.ts index 8edddb333..b8dc4fee6 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.ts @@ -1,8 +1,6 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import { firstRangeSqueeze } from "./firstRangeSqueeze"; -export /** Inclusive maximum length for a rehearsal-usable score display name. */ const MAX_SCORE_DISPLAY_NAME_LENGTH = 80; - /** Tonight's first trusted attached score, optionally paired with the first range. */ export type FirstScoreCheck = { fileName: string; @@ -13,7 +11,6 @@ export type FirstScoreCheck = { }; const SCORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; -const RESERVED_SCORE_STEM = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu; /** Return whether a display name contains a forbidden path or control character. */ function hasForbiddenScoreNameChar(value: string): boolean { @@ -32,36 +29,30 @@ function isRuntimeObject(value: unknown): value is Record { } /** - * Admit only a display basename for an attached score PDF. + * Admit the native attachment's PDF file name as literal display metadata. * - * Path separators, control characters, reserved Win32 device stems, parent - * traversal, leading or trailing whitespace, and overlong names fail closed. - * This is display copy only: it is not a filesystem path, PDF parser input, - * or native score-storage authority. + * Native attachment owns filesystem validation and can legitimately persist + * long names, surrounding spaces, repeated dots, and platform-reserved stems. + * This UI helper therefore avoids imposing a second basename policy. It only + * rejects values that cannot represent a PDF display name safely in this + * copy surface; the value is never used to rebuild a filesystem path. */ export function trustedScoreFileName(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - if (value.length < 5 || value.length > MAX_SCORE_DISPLAY_NAME_LENGTH) { - return null; - } - if (value !== value.trim() || hasForbiddenScoreNameChar(value) || value.includes("..")) { + if (typeof value !== "string" || value.length === 0 || hasForbiddenScoreNameChar(value)) { return null; } - if (!/\.pdf$/iu.test(value)) { - return null; - } - const stem = value.slice(0, -4); - if (!stem || stem.endsWith(".") || stem.endsWith(" ") || RESERVED_SCORE_STEM.test(stem)) { + + const withoutTrailingSpaces = value.replace(/ +$/u, ""); + if (!/\.pdf$/iu.test(withoutTrailingSpaces) || withoutTrailingSpaces.length <= 4) { return null; } + return value; } /** * Admit a score attachment only when its id matches the native UUID allowlist - * and its file name is a trusted display basename. Extra keys fail closed. + * and its file name is safe literal display metadata. Extra keys fail closed. */ export function trustedScoreAttachment( value: unknown From b4330289912c7da758254c373e062967a6b4196c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:10:54 -0700 Subject: [PATCH 04/13] test(workspace): reproduce reopened score openability gap --- .../src/features/workspace/firstScoreCheck.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts index 0f77386c0..0b048ebd8 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts @@ -129,6 +129,16 @@ describe("firstScoreCheck", () => { }); }); + it("does not advertise openability without a live project workspace", () => { + expect( + firstScoreCheck( + songWithAttachments([{ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }]), + null, + false + ) + ).toBeNull(); + }); + it("still names the score when the selected part has no playable span", () => { expect( firstScoreCheck( From 77db6cdf7a4701b0f53c25084f9622aa652681d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:11:17 -0700 Subject: [PATCH 05/13] fix(workspace): require live score workspace before open guidance --- .../src/features/workspace/firstScoreCheck.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.ts b/apps/desktop/src/features/workspace/firstScoreCheck.ts index b8dc4fee6..aa02d73cd 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.ts @@ -83,16 +83,18 @@ export function trustedScoreAttachment( /** * Pick the first trusted attached score a player should open in Score. * - * Skips malformed collection members instead of treating them as authority. - * When a playable range also exists, pair it so the map names the notes to - * check on the page. Runtime roots are untrusted; this never opens, reads, - * or parses PDF bytes. + * Persisted attachment metadata is not proof that Score can read the native + * copy. A reopened `.bscope` song has the metadata but no live project + * workspace, so callers must explicitly pass `scoreWorkspaceAvailable=false` + * and the helper falls back rather than advertising an impossible open action. + * Runtime roots are untrusted; this never opens, reads, or parses PDF bytes. */ export function firstScoreCheck( song: RehearsalSong | unknown, - activeRole: string | null = null + activeRole: string | null = null, + scoreWorkspaceAvailable = true ): FirstScoreCheck | null { - if (!isRuntimeObject(song) || !Array.isArray(song.scoreAttachments)) { + if (!scoreWorkspaceAvailable || !isRuntimeObject(song) || !Array.isArray(song.scoreAttachments)) { return null; } From 13724244603cfe691c32e43f202a1605ddbbe8b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:12:38 -0700 Subject: [PATCH 06/13] fix(workspace): gate score-open guidance on live project context --- apps/desktop/src/features/workspace/Workspace.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index cbae4e5c7..c1ee8d23f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -164,7 +164,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); - const firstScore = useMemo(() => firstScoreCheck(song, activeRole), [activeRole, song]); + const scoreWorkspaceAvailable = useMemo( + () => safeProjectBootstrapSummary(sourceBootstrap) !== null, + [sourceBootstrap] + ); + const firstScore = useMemo( + () => firstScoreCheck(song, activeRole, scoreWorkspaceAvailable), + [activeRole, scoreWorkspaceAvailable, song] + ); const firstScoreCopy = firstScore ? fillRangeCopy( t( @@ -538,4 +545,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file From dd17febbbb3b35d9e322ac50a200c0fae51a0cbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:13:45 -0700 Subject: [PATCH 07/13] test(workspace): cover reopened score fallback --- .../src/features/workspace/Workspace.test.tsx | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 48527984d..5cfeaaff1 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -8,6 +8,19 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const LIVE_SCORE_BOOTSTRAP: ProjectBootstrapSummary = { + projectId: "project-1", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-1", + cacheRoot: "/tmp/bandscope/cache/project-1", + tempRoot: "/tmp/bandscope/temp/project-1", + source: { + sourcePath: "/Users/test/Music/late-night-set.wav", + fileName: "late-night-set.wav", + extension: "wav", + fileSizeBytes: 1_024_000 + } +}; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -203,7 +216,7 @@ describe("Workspace", () => { { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } ]; - render(); + render(); const callout = screen.getByTestId("first-score-check"); expect(callout).toHaveTextContent("Tonight's first score check"); @@ -212,6 +225,20 @@ describe("Workspace", () => { ); }); + it("does not advertise a persisted score as openable after project reopen", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.scoreAttachments = [ + { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } + ]; + + render(); + + expect(screen.getByTestId("first-score-check")).toHaveTextContent( + "Add a score in Score, or check tonight's first range by ear before the first section." + ); + }); + it("asks the player to add a score when no trusted attachment exists", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -235,7 +262,7 @@ describe("Workspace", () => { overlapWarnings: [] })); - render(); + render(); expect(screen.getByTestId("first-score-check")).toHaveTextContent( "Open opener.pdf in Score and mark tonight's first range on the page." @@ -249,7 +276,7 @@ describe("Workspace", () => { { id: "3f2c8f0e-1a2b-4c3d-8e9f-001122334455", fileName: "opener.pdf" } ]; - render(); + render(); fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); expect(screen.getByTestId("first-score-check")).toHaveTextContent( @@ -387,4 +414,4 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); -}); +}); \ No newline at end of file From 35dc521f03711d749771751ecf39b904f193057d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:10:50 +0900 Subject: [PATCH 08/13] test(workspace): require semantic trusted score metadata --- apps/desktop/src/features/workspace/firstScoreCheck.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts index 0b048ebd8..c684e6443 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.test.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.test.ts @@ -70,10 +70,10 @@ describe("trustedScoreFileName", () => { }); describe("trustedScoreAttachment", () => { - it("admits a lowercase UUID plus trusted PDF basename", () => { + it("translates persisted generic attachment keys into semantic score metadata", () => { expect( trustedScoreAttachment({ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }) - ).toEqual({ id: TRUSTED_SCORE_ID, fileName: "opener.pdf" }); + ).toEqual({ scoreId: TRUSTED_SCORE_ID, scoreFileName: "opener.pdf" }); }); it("fails closed on extra keys, inherited members, and malformed ids", () => { From 8cd6756ef242d99fc323181b21b58f96fe24c731 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:11:25 +0900 Subject: [PATCH 09/13] fix(workspace): translate score attachment wire names --- .../src/features/workspace/firstScoreCheck.ts | 88 ++++++++++++------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstScoreCheck.ts b/apps/desktop/src/features/workspace/firstScoreCheck.ts index aa02d73cd..6ad342c44 100644 --- a/apps/desktop/src/features/workspace/firstScoreCheck.ts +++ b/apps/desktop/src/features/workspace/firstScoreCheck.ts @@ -10,13 +10,24 @@ export type FirstScoreCheck = { highestNote?: string; }; +/** Semantic score-attachment metadata used after the persisted compatibility boundary. */ +export type TrustedScoreAttachment = { + scoreId: string; + scoreFileName: string; +}; + const SCORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; /** Return whether a display name contains a forbidden path or control character. */ -function hasForbiddenScoreNameChar(value: string): boolean { - for (const character of value) { - const code = character.charCodeAt(0); - if (code <= 0x1f || code === 0x7f || character === "/" || character === "\\") { +function hasForbiddenScoreNameChar(scoreFileName: string): boolean { + for (const character of scoreFileName) { + const characterCode = character.charCodeAt(0); + if ( + characterCode <= 0x1f || + characterCode === 0x7f || + character === "/" || + character === "\\" + ) { return true; } } @@ -37,47 +48,62 @@ function isRuntimeObject(value: unknown): value is Record { * rejects values that cannot represent a PDF display name safely in this * copy surface; the value is never used to rebuild a filesystem path. */ -export function trustedScoreFileName(value: unknown): string | null { - if (typeof value !== "string" || value.length === 0 || hasForbiddenScoreNameChar(value)) { +export function trustedScoreFileName(scoreFileNameCandidate: unknown): string | null { + if ( + typeof scoreFileNameCandidate !== "string" || + scoreFileNameCandidate.length === 0 || + hasForbiddenScoreNameChar(scoreFileNameCandidate) + ) { return null; } - const withoutTrailingSpaces = value.replace(/ +$/u, ""); - if (!/\.pdf$/iu.test(withoutTrailingSpaces) || withoutTrailingSpaces.length <= 4) { + const scoreFileNameWithoutTrailingSpaces = scoreFileNameCandidate.replace(/ +$/u, ""); + if ( + !/\.pdf$/iu.test(scoreFileNameWithoutTrailingSpaces) || + scoreFileNameWithoutTrailingSpaces.length <= 4 + ) { return null; } - return value; + return scoreFileNameCandidate; } /** * Admit a score attachment only when its id matches the native UUID allowlist * and its file name is safe literal display metadata. Extra keys fail closed. + * + * Persisted `RehearsalSong.scoreAttachments` retains the established `id` and + * `fileName` wire keys for project compatibility. This function is the + * anti-corruption boundary that translates those generic wire names into the + * semantic `scoreId` and `scoreFileName` names used by workspace logic. */ export function trustedScoreAttachment( - value: unknown -): { id: string; fileName: string } | null { - if (!isRuntimeObject(value)) { + attachmentCandidate: unknown +): TrustedScoreAttachment | null { + if (!isRuntimeObject(attachmentCandidate)) { return null; } - const keys = Object.keys(value); - if (keys.length !== 2) { + const attachmentKeys = Object.keys(attachmentCandidate); + if (attachmentKeys.length !== 2) { return null; } if ( - !Object.prototype.hasOwnProperty.call(value, "id") || - !Object.prototype.hasOwnProperty.call(value, "fileName") + !Object.prototype.hasOwnProperty.call(attachmentCandidate, "id") || + !Object.prototype.hasOwnProperty.call(attachmentCandidate, "fileName") ) { return null; } - if (typeof value.id !== "string" || !SCORE_ID_PATTERN.test(value.id)) { + if ( + typeof attachmentCandidate.id !== "string" || + !SCORE_ID_PATTERN.test(attachmentCandidate.id) + ) { return null; } - const fileName = trustedScoreFileName(value.fileName); - if (fileName === null) { + const scoreFileName = trustedScoreFileName(attachmentCandidate.fileName); + if (scoreFileName === null) { return null; } - return { id: value.id, fileName }; + return { scoreId: attachmentCandidate.id, scoreFileName }; } /** @@ -98,24 +124,24 @@ export function firstScoreCheck( return null; } - for (const attachment of song.scoreAttachments) { - const trusted = trustedScoreAttachment(attachment); - if (trusted === null) { + for (const scoreAttachmentCandidate of song.scoreAttachments) { + const trustedScoreMetadata = trustedScoreAttachment(scoreAttachmentCandidate); + if (trustedScoreMetadata === null) { continue; } - const range = firstRangeSqueeze(song as RehearsalSong, activeRole); - if (range) { + const firstPlayableRange = firstRangeSqueeze(song as RehearsalSong, activeRole); + if (firstPlayableRange) { return { - fileName: trusted.fileName, - sectionLabel: range.sectionLabel, - roleName: range.roleName, - lowestNote: range.lowestNote, - highestNote: range.highestNote + fileName: trustedScoreMetadata.scoreFileName, + sectionLabel: firstPlayableRange.sectionLabel, + roleName: firstPlayableRange.roleName, + lowestNote: firstPlayableRange.lowestNote, + highestNote: firstPlayableRange.highestNote }; } - return { fileName: trusted.fileName }; + return { fileName: trustedScoreMetadata.scoreFileName }; } return null; From 5af64f5c3ddc85b237a4426678de0233ee4f5fdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:15:40 +0900 Subject: [PATCH 10/13] docs(score): state live workspace fallback contract --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec1afa31c..1f4aebefc 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, the next instrument check, and the first attached score to open in Score + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and the first validated attached score to open in Score only while a live Score workspace is available; reopened projects without live workspace authority or untrusted score metadata fall back to adding a score or checking the range by ear - 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 From 5a2abb1aa404eb0df133cbaeade44439621e56d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:16:18 +0900 Subject: [PATCH 11/13] docs(score): align agent score-open contract --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 50390359d..7f5cb0c56 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. The ready workspace names tonight's first playable range and the first attached score to open in Score. +- 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, only when validated attachment metadata and a live Score workspace are both available, the first attached score to open in Score; reopened metadata-only projects or untrusted score metadata fall back to adding a score or checking the range by ear. - 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. From 893b87a53faaa08f3f972a4dc264c47ff9c83511 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:16:50 +0900 Subject: [PATCH 12/13] docs(score): document unavailable score fallback --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f2e6bca..56a7e63fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +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 attached score on the ready rehearsal map and tell the player to open it in Score, or add a score and check the first range by ear. +- Name tonight's first validated attached score on the ready rehearsal map only while a live Score workspace is available; reopened metadata-only projects or untrusted score metadata fall back to adding a score or checking the first range by ear. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. From 8099e3b2525723474aca09db4d669167035263b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:17:15 +0900 Subject: [PATCH 13/13] docs(score): align Claude score-open contract --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cddaae75f..739634a57 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, the next instrument check, and the first score to open in Score. `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 the next instrument check; it names a score to open only when score attachment metadata is validated and a live Score workspace is available, otherwise reopened metadata-only projects or untrusted score metadata fall back to adding a score or checking the range by ear. `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.