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.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,");
+ });
+});
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..097639b46
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstCueSheetLead.test.ts
@@ -0,0 +1,140 @@
+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("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("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) => ({
+ ...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..e28c5f137
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstCueSheetLead.ts
@@ -0,0 +1,139 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import type { TranslationKey } from "../../i18n";
+import type { CueSheetLeadRow } from "../../lib/export";
+import { fillRangeCopy, firstRangeSqueezeTarget, 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);
+}
+
+/** Preserve literal source-row text while trimming surrounding whitespace. */
+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.
+ *
+ * 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 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;
+
+ if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) {
+ return null;
+ }
+
+ 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;
+ }
+
+ const roleValue = sectionValue.roles[target.roleIndex];
+ if (!isRuntimeObject(roleValue) || !isRuntimeObject(roleValue.range)) {
+ return null;
+ }
+ if (meaningfulRangeText(roleValue.id) !== target.roleId) {
+ return null;
+ }
+ if (currentActiveRole && target.roleId !== currentActiveRole) {
+ 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;
+ }
+
+ 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
+ }
+ )
+ };
+}
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) => {
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.