From f1c22eacceb4a28ef26c0ac9ea411ae5cb6eb6b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 09:22:13 +0000 Subject: [PATCH 1/8] feat(workspace): lead the cue sheet with tonight's first action The first-range card names downloading tonight's first-action sheet. generateCueSheetCsv prepends a fail-closed lead row so the export starts with the same playable-range check the board already shows. Formula-shaped harmony stays literal until escapeCsvField neutralizes it. --- AGENTS.md | 2 +- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 68 ++++++++++++++ .../src/features/workspace/Workspace.tsx | 14 ++- .../workspace/firstCueSheetLead.test.ts | 67 ++++++++++++++ .../features/workspace/firstCueSheetLead.ts | 89 +++++++++++++++++++ apps/desktop/src/lib/export.test.ts | 25 ++++++ apps/desktop/src/lib/export.ts | 45 +++++++--- apps/desktop/src/locales/en/common.json | 2 + apps/desktop/src/locales/ko/common.json | 2 + docs/architecture/overview.md | 2 +- docs/doctoring/cuesheet-first-action.md | 49 ++++++++++ 14 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstCueSheetLead.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCueSheetLead.ts create mode 100644 docs/doctoring/cuesheet-first-action.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..c030ac5d2 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 offers a download of tonight's first-action cue sheet, so the export starts with what to play first. - 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..d875b08db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,10 +82,10 @@ 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 a download of tonight's first-action cue sheet - 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 + - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form, with the cue sheet leading with tonight's first playable-range action ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..d45b4c2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Lead the cue sheet with tonight's first playable-range action and name that download on the first-range card. - 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..3bdab7859 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 a download of tonight's first-action cue sheet. `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..879e76f20 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -151,6 +151,9 @@ describe("Workspace", () => { expect(callout).toHaveTextContent( "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." ); + expect(callout).toContainElement( + screen.getByRole("button", { name: "Download tonight's first-action sheet" }) + ); }); it("asks for an ear check when the selected part has no named span", () => { @@ -196,6 +199,70 @@ describe("Workspace", () => { ); }); + it("names tonight's first-action download and leads the cue sheet with that row", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const createObjectUrl = vi.fn(() => "blob:cuesheet"); + const revokeObjectUrl = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectUrl + }); + + render(); + + const callout = screen.getByTestId("first-range-squeeze"); + const download = screen.getByRole("button", { name: "Download tonight's first-action sheet" }); + expect(callout).toContainElement(download); + + fireEvent.click(download); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const csv = await blob.text(); + const lines = csv.split("\n"); + expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes"); + expect(lines[1]).toBe( + "Tonight first,Straight eighths with a late snare feel,Bass Guitar,C#m7,Hold through the pickup before the downbeat.,high,Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." + ); + expect(lines[2]).toContain("verse,Straight eighths with a late snare feel,Bass Guitar"); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:cuesheet"); + }); + + it("does not invent a first-action lead when the first range still needs an ear check", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + const createObjectUrl = vi.fn(() => "blob:cuesheet-missing"); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn() + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-action sheet" })); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const lines = (await blob.text()).split("\n"); + expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes"); + expect(lines[1]).toMatch(/^verse,/); + expect(lines.some((line) => line.startsWith("Tonight first,"))).toBe(false); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -325,5 +392,6 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "오늘 먼저 할 일 시트 받기" })).toBeTruthy(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..b4ae27fb1 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 { firstCueSheetLead } from "./firstCueSheetLead"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -228,7 +229,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp /** Documented. */ const handleExportCueSheet = () => { - const csv = generateCueSheetCsv(song); + const csv = generateCueSheetCsv(song, { leadRow: firstCueSheetLead(song, activeRole, t) }); downloadTextFile(csv, "text/csv;charset=utf-8;", `${sanitizeFilename(song.title)}_cuesheet.csv`); }; @@ -308,6 +309,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp >

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts new file mode 100644 index 000000000..4e40db122 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts @@ -0,0 +1,67 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { createTranslator } from "../../i18n"; +import { firstCueSheetLead } from "./firstCueSheetLead"; + +describe("firstCueSheetLead", () => { + const t = createTranslator("en"); + + it("leads with the first clashing span and the instrument-check next action", () => { + expect(firstCueSheetLead(createDemoRehearsalSong(), null, t)).toEqual({ + section: "Tonight first", + groove: "Straight eighths with a late snare feel", + role: "Bass Guitar", + harmony: "C#m7", + cue: "Hold through the pickup before the downbeat.", + priority: "high", + notes: "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." + }); + }); + + it("limits the lead row to the selected part", () => { + const lead = firstCueSheetLead(createDemoRehearsalSong(), "lead-vocal", t); + + expect(lead?.role).toBe("Lead Vocal"); + expect(lead?.harmony).toBe("C#m7"); + expect(lead?.notes).toBe( + "Lead Vocal sits G#3–C#5 in verse. Hear that clash on your instrument before the verse." + ); + }); + + it("uses the check copy when the first span has no clash", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + overlapWarnings: [] + })); + + expect(firstCueSheetLead(song, null, t)?.notes).toBe( + "Bass Guitar sits C#2–E3 in verse. Check that span on your instrument before the verse." + ); + }); + + it("returns null when no named span exists", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + + expect(firstCueSheetLead(song, null, t)).toBeNull(); + }); + + it("fails closed on malformed runtime collections after a squeeze match is impossible", () => { + expect(firstCueSheetLead(null as unknown as RehearsalSong, null, t)).toBeNull(); + }); + + it("keeps formula-shaped harmony literal so CSV escaping can neutralize it later", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + harmony: { chord: "=Cmaj7", functionLabel: "vi pedal anchor", source: "model" } + }; + + expect(firstCueSheetLead(song, "bass-guitar", t)?.harmony).toBe("=Cmaj7"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.ts new file mode 100644 index 000000000..84cacb505 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.ts @@ -0,0 +1,89 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import type { TranslationKey } from "../../i18n"; +import type { CueSheetLeadRow } from "../../lib/export"; +import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Documented. */ +type Translator = (key: TranslationKey) => string; + +/** Documented. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Documented. */ +function runtimeText(value: unknown): string { + return meaningfulRangeText(value) ?? ""; +} + +/** + * Build the cue-sheet lead row for tonight's first playable-range action. + * + * Fail closed when the squeeze cannot be matched to a concrete role on the + * untrusted song payload, so the export never invents a first action. + */ +export function firstCueSheetLead( + song: RehearsalSong, + activeRole: string | null, + t: Translator +): CueSheetLeadRow | null { + const squeeze = firstRangeSqueeze(song, activeRole); + if (!squeeze) { + return null; + } + + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + continue; + } + if (meaningfulRangeText(sectionValue.label) !== squeeze.sectionLabel) { + continue; + } + + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !isRuntimeObject(roleValue.range)) { + continue; + } + if (activeRole && meaningfulRangeText(roleValue.id) !== activeRole) { + continue; + } + if (meaningfulRangeText(roleValue.name) !== squeeze.roleName) { + continue; + } + if ( + meaningfulRangeText(roleValue.range.lowestNote) !== squeeze.lowestNote + || meaningfulRangeText(roleValue.range.highestNote) !== squeeze.highestNote + ) { + continue; + } + + const harmony = isRuntimeObject(roleValue.harmony) ? runtimeText(roleValue.harmony.chord) : ""; + const cue = isRuntimeObject(roleValue.cue) ? runtimeText(roleValue.cue.value) : ""; + + return { + section: t("workspaceCueSheetFirstActionSection"), + groove: runtimeText(sectionValue.groove), + role: squeeze.roleName, + harmony, + cue, + priority: runtimeText(roleValue.rehearsalPriority), + notes: fillRangeCopy( + t(squeeze.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), + { + roleName: squeeze.roleName, + lowestNote: squeeze.lowestNote, + highestNote: squeeze.highestNote, + sectionLabel: squeeze.sectionLabel + } + ) + }; + } + } + + return null; +} diff --git a/apps/desktop/src/lib/export.test.ts b/apps/desktop/src/lib/export.test.ts index 265e983d4..ed308e193 100644 --- a/apps/desktop/src/lib/export.test.ts +++ b/apps/desktop/src/lib/export.test.ts @@ -126,6 +126,31 @@ describe("export generation", () => { expect(lines[1]).toBe('verse,swing,Bass,\'=Cmaj7,"1, 2, 3",high,setup | simple'); }); + it("leads the cue sheet with tonight's first action when a lead row is provided", () => { + const csv = generateCueSheetCsv(mockSong, { + leadRow: { + section: "Tonight first", + groove: "swing", + role: "Bass", + harmony: "=Cmaj7", + cue: "1, 2, 3", + priority: "high", + notes: "Bass sits C2–C3 in verse. Check that span on your instrument before the verse." + } + }); + const lines = csv.split("\n"); + expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes"); + expect(lines[1]).toBe( + 'Tonight first,swing,Bass,\'=Cmaj7,"1, 2, 3",high,Bass sits C2–C3 in verse. Check that span on your instrument before the verse.' + ); + expect(lines[2]).toBe('verse,swing,Bass,\'=Cmaj7,"1, 2, 3",high,setup | simple'); + }); + + it("does not invent a first action when the lead row is omitted or null", () => { + expect(generateCueSheetCsv(mockSong).split("\n")).toHaveLength(2); + expect(generateCueSheetCsv(mockSong, { leadRow: null }).split("\n")).toHaveLength(2); + }); + it("generates chart summary JSON", () => { const jsonStr = generateChartSummaryJson(mockSong); const parsed = JSON.parse(jsonStr); diff --git a/apps/desktop/src/lib/export.ts b/apps/desktop/src/lib/export.ts index 3d4493b1d..9521dcbbc 100644 --- a/apps/desktop/src/lib/export.ts +++ b/apps/desktop/src/lib/export.ts @@ -34,25 +34,48 @@ export function escapeCsvField(value: string): string { return escapedValue; } +/** One cue-sheet data row, including the optional tonight-first lead action. */ +export type CueSheetLeadRow = { + section: string; + groove: string; + role: string; + harmony: string; + cue: string; + priority: string; + notes: string; +}; + +/** Documented. */ +function formatCueSheetRow(row: CueSheetLeadRow): string { + return [row.section, row.groove, row.role, row.harmony, row.cue, row.priority, row.notes] + .map(escapeCsvField) + .join(","); +} + /** Documented. */ -export function generateCueSheetCsv(song: RehearsalSong): string { +export function generateCueSheetCsv( + song: RehearsalSong, + options?: { leadRow?: CueSheetLeadRow | null } +): string { const headers = ["Section", "Groove", "Role", "Harmony", "Cue", "Priority", "Notes"]; const rows: string[] = [headers.join(",")]; + if (options?.leadRow) { + rows.push(formatCueSheetRow(options.leadRow)); + } + for (const section of song.sections) { for (const role of section.roles) { const notes = [role.setupNote, role.simplification].filter(Boolean).join(" | "); - const row = [ - section.label, - section.groove, - role.name, - role.harmony.chord, - role.cue.value, - role.rehearsalPriority, + rows.push(formatCueSheetRow({ + section: section.label, + groove: section.groove, + role: role.name, + harmony: role.harmony.chord, + cue: role.cue.value, + priority: role.rehearsalPriority, notes - ].map(escapeCsvField); - - rows.push(row.join(",")); + })); } } diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..c84e915e8 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,8 @@ "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.", + "workspaceFirstRangeDownloadSheet": "Download tonight's first-action sheet", + "workspaceCueSheetFirstActionSection": "Tonight first", "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..d6488a292 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,8 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstRangeDownloadSheet": "오늘 먼저 할 일 시트 받기", + "workspaceCueSheetFirstActionSection": "오늘 먼저", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..176328820 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -23,7 +23,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c ## Exported rehearsal deliverables -- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model. +- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model. The cue sheet leads with tonight's first playable-range action so a player can start from the same check the board already named. - Exported artifacts should stay compact and rehearsal-friendly rather than becoming DAW sessions or engraved scores. ## Delivery flow diff --git a/docs/doctoring/cuesheet-first-action.md b/docs/doctoring/cuesheet-first-action.md new file mode 100644 index 000000000..b54c8a4a5 --- /dev/null +++ b/docs/doctoring/cuesheet-first-action.md @@ -0,0 +1,49 @@ +# Cue-sheet first-action lead + +## Decision + +The ready workspace names tonight's first playable range and offers a download of tonight's first-action cue sheet. `generateCueSheetCsv` prepends an optional lead row. `export.ts` stays locale-free; the workspace computes the localized lead through `firstCueSheetLead`. If that helper cannot match the squeeze to a concrete role on the untrusted song payload, the lead is omitted rather than invented. + +```mermaid +flowchart LR + A[Untrusted song payload] --> B[firstRangeSqueeze] + B -->|named span| C[firstCueSheetLead] + B -->|no span| Z[Omit lead row] + C -->|matched role| D[Localized lead row] + C -->|no match| Z + D --> E[escapeCsvField] + Z --> F[section x role rows] + E --> F + F --> G[Cue-sheet CSV] +``` + +## Security Notes + +### Attack surface + +Cue-sheet CSV is derived from untrusted analysis payloads. Section labels, groove, role names, harmony, cues, priorities, and notes can carry formula-shaped values (`=`, `+`, `-`, `@`) plus commas, quotes, and newlines. + +### Trust boundary + +`escapeCsvField` is the only CSV-cell sanitizer. `firstCueSheetLead` treats the song as untrusted runtime data and fails closed. Lead-row values stay literal until that sanitizer runs. Filename sanitization for the download remains `sanitizeFilename`. + +### Mitigations + +- Prefix formula-leading cells with a single quote before structural quoting. +- Do not invent a first-action row when the squeeze cannot be matched to a concrete role. +- Keep formula-shaped harmony literal in the lead helper so escaping stays centralized in `export.ts`. +- Do not weaken or duplicate the existing formula-injection tests. + +### Test points + +- `apps/desktop/src/lib/export.test.ts` proves a lead row is formula-escaped and that a missing/null lead does not add a row. +- `apps/desktop/src/features/workspace/firstCueSheetLead.test.ts` proves fail-closed matching and literal formula-shaped harmony. +- `apps/desktop/src/features/workspace/Workspace.test.tsx` proves the first-range card names the download and that the file starts with tonight's first action. + +### Realistic threats + +Opening the CSV in a spreadsheet can execute formula injection from model-generated harmony, cue, or notes if escaping is skipped or bypassed. A fabricated lead row would also teach the wrong first action. + +### Remaining risk + +Spreadsheet software may still interpret escaped cells depending on locale and import settings. NUL-byte and other formula-injection bypasses stay fail-closed in `escapeCsvField` and are not suppressed here. From 89cb0291f49330b62c25c4be7d5d7aa5687c8277 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:45:58 +0900 Subject: [PATCH 2/8] test(workspace): pin cue-sheet lead to repeated section --- .../workspace/firstCueSheetLead.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts index 4e40db122..381f15580 100644 --- a/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts @@ -40,6 +40,52 @@ describe("firstCueSheetLead", () => { ); }); + it("keeps the lead row on the exact repeated section selected by the squeeze", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const bass = section.roles[0]!; + + song.sections = [ + { + ...section, + id: "repeat-verse-earlier", + label: "verse", + groove: "Earlier groove", + roles: [ + { + ...bass, + harmony: { ...bass.harmony, chord: "Am7" }, + cue: { ...bass.cue, value: "Earlier cue" }, + rehearsalPriority: "low", + overlapWarnings: [] + } + ] + }, + { + ...section, + id: "repeat-verse-later", + label: "verse", + groove: "Later clash groove", + roles: [ + { + ...bass, + harmony: { ...bass.harmony, chord: "D7" }, + cue: { ...bass.cue, value: "Later cue" }, + rehearsalPriority: "high", + overlapWarnings: ["Register clash"] + } + ] + } + ]; + + expect(firstCueSheetLead(song, "bass-guitar", t)).toMatchObject({ + groove: "Later clash groove", + harmony: "D7", + cue: "Later cue", + priority: "high" + }); + }); + it("returns null when no named span exists", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ From 363d6160f1b469d75dd55092d03a49792f7a6d63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:50:56 +0900 Subject: [PATCH 3/8] fix(workspace): retain first-range target identity --- .../features/workspace/firstRangeSqueeze.ts | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts index 47270d2a9..6e931b1a6 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -9,6 +9,15 @@ export type FirstRangeSqueeze = { overlapWarning?: string; }; +/** Exact runtime position and identifiers that produced a first-range squeeze. */ +export type FirstRangeSqueezeTarget = { + squeeze: FirstRangeSqueeze; + sectionIndex: number; + roleIndex: number; + sectionId?: string; + roleId: string; +}; + const NATURAL_PITCH_CLASS = { C: 0, D: 2, @@ -85,26 +94,24 @@ export function playableRange( } /** - * Pick the first playable range a player should check before the next section. + * Pick the exact section/role target behind the first playable range. * - * Prefers a named span that also carries a clash warning so the board names - * the squeeze that will waste rehearsal time. Falls back to the first named - * span when no clash is present. Runtime roots and collection members are - * treated as untrusted; malformed evidence is isolated instead of crashing - * the buyer-visible workspace or becoming playable-range authority. + * The positional identity is retained alongside stable identifiers so a + * consumer that needs the source section does not have to rematch lossy + * display values such as repeated section labels or duplicated ranges. */ -export function firstRangeSqueeze( +export function firstRangeSqueezeTarget( song: RehearsalSong, activeRole: string | null = null -): FirstRangeSqueeze | null { +): FirstRangeSqueezeTarget | null { const runtimeSong: unknown = song; if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { return null; } - let fallback: FirstRangeSqueeze | null = null; + let fallback: FirstRangeSqueezeTarget | null = null; - for (const sectionValue of runtimeSong.sections) { + for (const [sectionIndex, sectionValue] of runtimeSong.sections.entries()) { if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { continue; } @@ -112,8 +119,9 @@ export function firstRangeSqueeze( if (!sectionLabel) { continue; } + const sectionId = meaningfulRangeText(sectionValue.id); - for (const roleValue of sectionValue.roles) { + for (const [roleIndex, roleValue] of sectionValue.roles.entries()) { if (!isRuntimeObject(roleValue)) { continue; } @@ -142,11 +150,17 @@ export function firstRangeSqueeze( } } - const candidate: FirstRangeSqueeze = { - sectionLabel, - roleName, - ...range, - overlapWarning + const candidate: FirstRangeSqueezeTarget = { + squeeze: { + sectionLabel, + roleName, + ...range, + overlapWarning + }, + sectionIndex, + roleIndex, + sectionId, + roleId }; if (overlapWarning) { @@ -162,6 +176,22 @@ export function firstRangeSqueeze( return fallback; } +/** + * Pick the first playable range a player should check before the next section. + * + * Prefers a named span that also carries a clash warning so the board names + * the squeeze that will waste rehearsal time. Falls back to the first named + * span when no clash is present. Runtime roots and collection members are + * treated as untrusted; malformed evidence is isolated instead of crashing + * the buyer-visible workspace or becoming playable-range authority. + */ +export function firstRangeSqueeze( + song: RehearsalSong, + activeRole: string | null = null +): FirstRangeSqueeze | null { + return firstRangeSqueezeTarget(song, activeRole)?.squeeze ?? null; +} + /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ export function fillRangeCopy(template: string, values: Record): string { return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (placeholder, token: string) => { From 2f66afe308edece81a80981a7b78539c9b3b6bed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:51:24 +0900 Subject: [PATCH 4/8] fix(workspace): use exact cue-sheet range target --- .../features/workspace/firstCueSheetLead.ts | 106 +++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.ts index 84cacb505..da4a916cc 100644 --- a/apps/desktop/src/features/workspace/firstCueSheetLead.ts +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.ts @@ -1,7 +1,7 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import type { TranslationKey } from "../../i18n"; import type { CueSheetLeadRow } from "../../lib/export"; -import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText } from "./firstRangeSqueeze"; +import { fillRangeCopy, firstRangeSqueezeTarget, meaningfulRangeText } from "./firstRangeSqueeze"; /** Documented. */ type Translator = (key: TranslationKey) => string; @@ -19,71 +19,75 @@ function runtimeText(value: unknown): string { /** * Build the cue-sheet lead row for tonight's first playable-range action. * - * Fail closed when the squeeze cannot be matched to a concrete role on the - * untrusted song payload, so the export never invents a first action. + * Retains the exact section/role target chosen by the range selector instead + * of rematching display labels, so repeated form labels cannot attach an + * earlier section's groove, harmony, cue, or priority to a later clash. */ export function firstCueSheetLead( song: RehearsalSong, activeRole: string | null, t: Translator ): CueSheetLeadRow | null { - const squeeze = firstRangeSqueeze(song, activeRole); - if (!squeeze) { + const target = firstRangeSqueezeTarget(song, activeRole); + if (!target) { return null; } + const { squeeze } = target; const runtimeSong: unknown = song; if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { return null; } - for (const sectionValue of runtimeSong.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { - continue; - } - if (meaningfulRangeText(sectionValue.label) !== squeeze.sectionLabel) { - continue; - } - - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue) || !isRuntimeObject(roleValue.range)) { - continue; - } - if (activeRole && meaningfulRangeText(roleValue.id) !== activeRole) { - continue; - } - if (meaningfulRangeText(roleValue.name) !== squeeze.roleName) { - continue; - } - if ( - meaningfulRangeText(roleValue.range.lowestNote) !== squeeze.lowestNote - || meaningfulRangeText(roleValue.range.highestNote) !== squeeze.highestNote - ) { - continue; - } - - const harmony = isRuntimeObject(roleValue.harmony) ? runtimeText(roleValue.harmony.chord) : ""; - const cue = isRuntimeObject(roleValue.cue) ? runtimeText(roleValue.cue.value) : ""; + const sectionValue = runtimeSong.sections[target.sectionIndex]; + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return null; + } + if (meaningfulRangeText(sectionValue.label) !== squeeze.sectionLabel) { + return null; + } + if (target.sectionId && meaningfulRangeText(sectionValue.id) !== target.sectionId) { + return null; + } - return { - section: t("workspaceCueSheetFirstActionSection"), - groove: runtimeText(sectionValue.groove), - role: squeeze.roleName, - harmony, - cue, - priority: runtimeText(roleValue.rehearsalPriority), - notes: fillRangeCopy( - t(squeeze.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), - { - roleName: squeeze.roleName, - lowestNote: squeeze.lowestNote, - highestNote: squeeze.highestNote, - sectionLabel: squeeze.sectionLabel - } - ) - }; - } + const roleValue = sectionValue.roles[target.roleIndex]; + if (!isRuntimeObject(roleValue) || !isRuntimeObject(roleValue.range)) { + return null; + } + if (meaningfulRangeText(roleValue.id) !== target.roleId) { + return null; + } + if (activeRole && target.roleId !== activeRole) { + return null; + } + if (meaningfulRangeText(roleValue.name) !== squeeze.roleName) { + return null; + } + if ( + meaningfulRangeText(roleValue.range.lowestNote) !== squeeze.lowestNote + || meaningfulRangeText(roleValue.range.highestNote) !== squeeze.highestNote + ) { + return null; } - return null; + const harmony = isRuntimeObject(roleValue.harmony) ? runtimeText(roleValue.harmony.chord) : ""; + const cue = isRuntimeObject(roleValue.cue) ? runtimeText(roleValue.cue.value) : ""; + + return { + section: t("workspaceCueSheetFirstActionSection"), + groove: runtimeText(sectionValue.groove), + role: squeeze.roleName, + harmony, + cue, + priority: runtimeText(roleValue.rehearsalPriority), + notes: fillRangeCopy( + t(squeeze.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), + { + roleName: squeeze.roleName, + lowestNote: squeeze.lowestNote, + highestNote: squeeze.highestNote, + sectionLabel: squeeze.sectionLabel + } + ) + }; } From 95ddbf7d27662164fc877284f3c6842d34af23b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 20:26:38 +0900 Subject: [PATCH 5/8] test(workspace): preserve cue-sheet none values --- .../workspace/firstCueSheetLead.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts index 381f15580..097639b46 100644 --- a/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts @@ -86,6 +86,33 @@ describe("firstCueSheetLead", () => { }); }); + it("preserves literal none values from the selected source row", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const bass = section.roles[0]!; + + song.sections[0] = { + ...section, + groove: "none", + roles: [ + { + ...bass, + harmony: { ...bass.harmony, chord: "none" }, + cue: { ...bass.cue, value: "none" }, + rehearsalPriority: "none" + }, + ...section.roles.slice(1) + ] + }; + + expect(firstCueSheetLead(song, "bass-guitar", t)).toMatchObject({ + groove: "none", + harmony: "none", + cue: "none", + priority: "none" + }); + }); + it("returns null when no named span exists", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ From f177efac566b28e8edc4d73f02e476c5fcc1aac8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 20:28:22 +0900 Subject: [PATCH 6/8] fix(workspace): preserve cue-sheet none values --- apps/desktop/src/features/workspace/firstCueSheetLead.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.ts index da4a916cc..bbc7c4c5f 100644 --- a/apps/desktop/src/features/workspace/firstCueSheetLead.ts +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.ts @@ -11,9 +11,9 @@ function isRuntimeObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Documented. */ +/** Preserve literal source-row text while trimming surrounding whitespace. */ function runtimeText(value: unknown): string { - return meaningfulRangeText(value) ?? ""; + return typeof value === "string" ? value.trim() : ""; } /** From 2b90c34db355a59b5bed2039bb668a62413560d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:25:30 +0900 Subject: [PATCH 7/8] test(workspace): reject stale cue-sheet role filters --- .../Workspace.cuesheet-stale-role.test.tsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.cuesheet-stale-role.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.cuesheet-stale-role.test.tsx b/apps/desktop/src/features/workspace/Workspace.cuesheet-stale-role.test.tsx new file mode 100644 index 000000000..c6a55992f --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.cuesheet-stale-role.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; +const originalCreateObjectUrl = URL.createObjectURL; +const originalRevokeObjectUrl = URL.revokeObjectURL; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace cue-sheet role filter lifecycle", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.restoreAllMocks(); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: originalCreateObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: originalRevokeObjectUrl + }); + }); + + it("exports the new song's first action when the previous role filter no longer exists", async () => { + setNavigatorLanguage("en-US"); + const firstSong = createDemoRehearsalSong(); + const nextSong = createDemoRehearsalSong(); + nextSong.id = "next-project"; + nextSong.title = "Next Project"; + nextSong.sections = nextSong.sections.map((section) => ({ + ...section, + roles: section.roles.filter((role) => role.id !== "lead-vocal"), + partGraph: section.partGraph.filter((node) => node.role_id !== "lead-vocal") + })); + + const createObjectUrl = vi.fn(() => "blob:next-cuesheet"); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn() + }); + + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + rerender(); + fireEvent.click( + screen.getByRole("button", { name: "Download tonight's first-action sheet" }) + ); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + const lines = (await blob.text()).split("\n"); + expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes"); + expect(lines[1]).toMatch(/^Tonight first,/); + expect(lines[1]).toContain(",Bass Guitar,"); + }); +}); From c9965e59561330ff2a9faf5eb71a11db46f22ecd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:26:40 +0900 Subject: [PATCH 8/8] fix(workspace): ignore stale cue-sheet role filters --- .../features/workspace/firstCueSheetLead.ts | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstCueSheetLead.ts b/apps/desktop/src/features/workspace/firstCueSheetLead.ts index bbc7c4c5f..e28c5f137 100644 --- a/apps/desktop/src/features/workspace/firstCueSheetLead.ts +++ b/apps/desktop/src/features/workspace/firstCueSheetLead.ts @@ -16,6 +16,47 @@ function runtimeText(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +/** + * Resolve a role filter against the song currently being exported. + * + * Workspace can replace its song without unmounting, so React state may still + * contain a role id from the previous project for one render. A well-formed + * current song that no longer contains that id means "no current filter"; + * malformed role evidence remains a fail-closed condition instead of silently + * broadening the export. + */ +function currentSongRoleFilter( + songValue: unknown, + activeRole: string | null +): string | null | undefined { + if (!activeRole) { + return null; + } + if (!isRuntimeObject(songValue) || !Array.isArray(songValue.sections)) { + return undefined; + } + + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return undefined; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { + return undefined; + } + const roleId = meaningfulRangeText(roleValue.id); + if (!roleId) { + return undefined; + } + if (roleId === activeRole) { + return activeRole; + } + } + } + + return null; +} + /** * Build the cue-sheet lead row for tonight's first playable-range action. * @@ -28,13 +69,18 @@ export function firstCueSheetLead( activeRole: string | null, t: Translator ): CueSheetLeadRow | null { - const target = firstRangeSqueezeTarget(song, activeRole); + const runtimeSong: unknown = song; + const currentActiveRole = currentSongRoleFilter(runtimeSong, activeRole); + if (currentActiveRole === undefined) { + return null; + } + + const target = firstRangeSqueezeTarget(song, currentActiveRole); if (!target) { return null; } const { squeeze } = target; - const runtimeSong: unknown = song; if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { return null; } @@ -57,7 +103,7 @@ export function firstCueSheetLead( if (meaningfulRangeText(roleValue.id) !== target.roleId) { return null; } - if (activeRole && target.roleId !== activeRole) { + if (currentActiveRole && target.roleId !== currentActiveRole) { return null; } if (meaningfulRangeText(roleValue.name) !== squeeze.roleName) {