diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..4308d30d4 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 chart; when a valid named playable range exists the chart starts with that action, and otherwise it omits `firstAction` rather than inventing one.
- 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..2b1cc790c 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 chart
- 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 chart JSON leading with tonight's first playable-range action
## Confidence, edits, and provenance
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..4dc730117 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Lead the chart JSON with tonight's first playable-range action and name that download on the rehearsal map.
- 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..791ac986c 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 chart. `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.chartExport.test.tsx b/apps/desktop/src/features/workspace/Workspace.chartExport.test.tsx
new file mode 100644
index 000000000..3e65b00b8
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.chartExport.test.tsx
@@ -0,0 +1,60 @@
+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 chart export contract", () => {
+ afterEach(() => {
+ setNavigatorLanguage(originalLanguage);
+ vi.restoreAllMocks();
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true,
+ value: originalCreateObjectUrl
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true,
+ value: originalRevokeObjectUrl
+ });
+ });
+
+ it("keeps the full-band chart lead stable when the UI role changes", async () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:full-band-chart");
+ 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("tab", { name: "Lead Vocal" }));
+ fireEvent.click(screen.getByRole("button", { name: "Download tonight's first-action chart" }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text()) as {
+ firstAction?: { role?: string };
+ sections?: Array<{ roles?: Array<{ name?: string }> }>;
+ };
+
+ expect(payload.firstAction?.role).toBe("Bass Guitar");
+ expect(payload.sections?.[0]?.roles?.map((role) => role.name)).toEqual(
+ expect.arrayContaining(["Bass Guitar", "Lead Vocal"])
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..666759057 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -196,6 +196,68 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first-action chart download and leads the file with that action", async () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ const createObjectUrl = vi.fn(() => "blob:chart");
+ 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 download = screen.getByRole("button", { name: "Download tonight's first-action chart" });
+ fireEvent.click(download);
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text());
+ expect(Object.keys(payload)[1]).toBe("firstAction");
+ expect(payload.firstAction).toEqual({
+ section: "verse",
+ role: "Bass Guitar",
+ lowestNote: "C#2",
+ highestNote: "E3",
+ next: "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."
+ });
+ expect(click).toHaveBeenCalledTimes(1);
+ expect(revokeObjectUrl).toHaveBeenCalledWith("blob:chart");
+ });
+
+ it("does not invent a first-action chart 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:chart-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 chart" }));
+
+ const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
+ const payload = JSON.parse(await blob.text());
+ expect(payload.firstAction).toBeUndefined();
+ expect(Object.keys(payload)).toEqual(["title", "headline", "sections"]);
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
@@ -325,5 +387,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..407b6ae00 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 { firstChartAction } from "./firstChartAction";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -234,7 +235,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
/** Documented. */
const handleExportChart = () => {
- const json = generateChartSummaryJson(song);
+ const json = generateChartSummaryJson(song, { firstAction: firstChartAction(song, activeRole, t) });
downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_chart.json`);
};
@@ -285,7 +286,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
className="min-h-10 border-white/10 bg-white/5 font-semibold text-slate-100 shadow-sm hover:bg-white/10 hover:text-white"
>
- Export Chart (JSON)
+ {t("workspaceFirstRangeDownloadChart")}