Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- After analysis, export, stems, and rehearsal-priority copy must enable the next action (download tonight's cue sheet, save a chart, share a handoff, or open the first lock-in section). Do not leave those ready-state cards as dead-end descriptions.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Last updated: 2026-03-11
- BandScope is not only a shell around chord labels, stems, and ranges.
- The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority.
- These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer.
- Ready-workspace export, stems, and rehearsal-priority cards must start tonight's next action: download the cue sheet, save a compact chart, share a handoff, or open the first lock-in section. Stem Lab remains a separate lane.

## Analysis target model

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Ready-workspace export, stems, and rehearsal-priority cards now name the next rehearsal action: download tonight's cue sheet, save a compact chart, share a handoff, or open the first lock-in section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.

After analysis, export, stems, and rehearsal-priority cards must name a next action. Do not leave those ready-state surfaces as dead-end descriptions.

Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.

## Common commands
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,9 @@ describe("App", () => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy();
expect(screen.getByText(/Stems/i)).toBeTruthy();
expect(screen.getByText(/^Stems$/i)).toBeTruthy();
expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy();
expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy();
expect(screen.getAllByRole("button", { name: /download tonight's cue sheet/i }).length).toBeGreaterThan(0);
});

it("renders a rehearsal song structure timeline from real section ranges", async () => {
Expand Down Expand Up @@ -923,7 +923,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
fireEvent.click(screen.getByRole("button", { name: /share a handoff file/i }));
const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());

Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,14 @@ describe("SectionRoadmap", () => {

expect(onSongUpdate).not.toHaveBeenCalled();
});

it("exposes a focusable id for each rehearsal section card", () => {
const song = createDemoRehearsalSong();

render(<SectionRoadmap song={song} activeRole={null} />);

const card = document.getElementById("workspace-section-verse-1");
expect(card).toBeTruthy();
expect(card?.getAttribute("tabindex")).toBe("-1");
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{song.sections.map((section) => (
<Card
key={section.id}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] ${
id={`workspace-section-${section.id}`}
tabIndex={-1}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
section.confidence.level === "low" ? "border-rose-300/30 bg-rose-950/30" : "border-white/10 bg-slate-950/80"
}`}
>
Expand Down
103 changes: 101 additions & 2 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe("Workspace", () => {
});

render(<Workspace song={song} sourceBootstrap={sourceBootstrap} />);
fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
fireEvent.click(screen.getByRole("button", { name: "Share a handoff file" }));

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
Expand Down Expand Up @@ -222,7 +222,7 @@ describe("Workspace", () => {
});

render(<Workspace song={song} sourceBootstrap={invalidSourceBootstrap} />);
fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
fireEvent.click(screen.getByRole("button", { name: "Share a handoff file" }));

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
Expand Down Expand Up @@ -270,4 +270,103 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});

it("names rehearsal-first export and priority actions after analysis", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

render(<Workspace song={song} />);

expect(screen.getByText("Print the cue sheet or send the handoff before you leave.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Share a handoff file" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Save a compact chart" })).toBeTruthy();
expect(screen.getByText("Start with verse — that is tonight's first lock-in.")).toBeTruthy();
expect(screen.getByText("Stems are not ready yet. Start with tonight's cue sheet.")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Download tonight's cue sheet" }).length).toBe(2);
});

it("localizes ready-workspace export and priority actions", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();

render(<Workspace song={song} />);

expect(screen.getByText("나가기 전에 큐시트를 출력하거나 핸드오프를 보내세요.")).toBeTruthy();
expect(screen.getByRole("button", { name: "다음 연습용 핸드오프 보내기" })).toBeTruthy();
expect(screen.getByText("오늘은 verse부터 잠그세요.")).toBeTruthy();
expect(screen.getByRole("button", { name: "이 구간 열기" })).toBeTruthy();
expect(screen.getByText("스템은 아직 준비되지 않았습니다. 오늘 큐시트로 먼저 시작하세요.")).toBeTruthy();
});

it("opens the first rehearsal-priority section from the priorities card", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("button", { name: "Open this section" }));

const card = document.getElementById("workspace-section-verse-1");
expect(card).toBeTruthy();
expect(scrollIntoView).toHaveBeenCalled();
expect(document.activeElement).toBe(card);
});

it("lets the stems card download tonight's cue sheet when stems are not ready", 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(<Workspace song={song} />);
const stemsCard = document.getElementById("workspace-stems-card");
expect(stemsCard).toBeTruthy();
fireEvent.click(screen.getAllByRole("button", { name: "Download tonight's cue sheet" })[1]!);

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
expect(blob.type).toContain("text/csv");
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:cuesheet");
});

it("keeps the priority action disabled when no section can be opened", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: []
};

render(<Workspace song={song} />);

expect(screen.getByText("Start with first pass — that is tonight's first lock-in.")).toBeTruthy();
expect((screen.getByRole("button", { name: "Open this section" }) as HTMLButtonElement).disabled).toBe(true);
});

it("matches a rehearsal-priority section by id when the label is not the stored key", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.exportSummary = {
...song.exportSummary,
focusSections: ["verse-1"]
};
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

render(<Workspace song={song} />);
expect(screen.getByText("Start with verse — that is tonight's first lock-in.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Open this section" }));
expect(document.activeElement).toBe(document.getElementById("workspace-section-verse-1"));
});
});
74 changes: 66 additions & 8 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro
}
}

/** Return the first rehearsal-priority section the player should open. */
function firstFocusSection(song: RehearsalSong): RehearsalSong["sections"][number] | undefined {
const requested = song.exportSummary?.focusSections?.[0]?.trim();
if (requested) {
const match = song.sections.find(
(section) => section.label === requested || section.id === requested
);
if (match) {
return match;
}
}
return song.sections[0];
}

/** Scroll and focus the matching section card on the rehearsal roadmap. */
function focusWorkspaceSection(sectionId: string): void {
const node = document.getElementById(`workspace-section-${sectionId}`);
if (!(node instanceof HTMLElement)) {
return;
}
node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
node.focus();
}

/** Documented. */
const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) {
return (
Expand Down Expand Up @@ -212,6 +236,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
const focusSection = firstFocusSection(song);
const focusLabel = focusSection?.label ?? t("workspaceFocusFallback");

/** Documented. */
const handleExportCueSheet = () => {
Expand Down Expand Up @@ -255,34 +281,40 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{song.exportSummary?.headline || t("workspaceRehearsalFallback")}
</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
<div id="workspace-export-actions" className="flex flex-col items-stretch gap-2 sm:items-end">
<p className="max-w-sm text-xs font-medium leading-5 text-slate-400 sm:text-right">{t("workspaceExportNextHint")}</p>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
size="sm"
onClick={handleExportCueSheet}
className="min-h-10 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 shadow-[0_10px_30px_rgba(34,211,238,0.16)] hover:bg-cyan-300/20 hover:text-white"
aria-label={t("workspaceExportCueSheet")}
>
<Download className="mr-2 size-4 text-cyan-200" aria-hidden="true" />
Export Cue Sheet (CSV)
{t("workspaceExportCueSheet")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportChart}
className="min-h-10 border-white/10 bg-white/5 font-semibold text-slate-100 shadow-sm hover:bg-white/10 hover:text-white"
aria-label={t("workspaceExportChart")}
>
<Download className="mr-2 size-4 text-slate-300" aria-hidden="true" />
Export Chart (JSON)
{t("workspaceExportChart")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportHandoff}
className="min-h-10 border-teal-300/25 bg-teal-300/10 font-semibold text-teal-50 shadow-sm hover:bg-teal-300/20 hover:text-white"
aria-label={t("workspaceExportHandoff")}
>
<Download className="mr-2 size-4 text-teal-200" aria-hidden="true" />
Export Handoff (JSON)
{t("workspaceExportHandoff")}
</Button>
</div>
</div>
</div>
</CardHeader>
Expand Down Expand Up @@ -318,16 +350,42 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
</section>

<section className="rounded-2xl border border-violet-300/20 bg-violet-300/[0.06] p-4">
<section id="workspace-stems-card" className="rounded-2xl border border-violet-300/20 bg-violet-300/[0.06] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-violet-200">{t("workspaceStemsLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">Stem lanes will appear when separation results are available.</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{t("workspaceStemsNextHint")}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExportCueSheet}
className="mt-3 min-h-10 border-violet-300/30 bg-violet-300/10 font-semibold text-violet-50 hover:bg-violet-300/20 hover:text-white"
aria-label={t("workspaceStemsDownloadCueSheet")}
>
<Download className="mr-2 size-4 text-violet-200" aria-hidden="true" />
{t("workspaceStemsDownloadCueSheet")}
</Button>
</section>

<section className="rounded-2xl border border-amber-300/20 bg-amber-300/[0.07] p-4">
<section id="workspace-priority-card" className="rounded-2xl border border-amber-300/20 bg-amber-300/[0.07] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-amber-200">{t("workspaceRehearsalPrioritiesLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">
Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}.
{t("workspacePrioritiesNextHint").replace("{section}", focusLabel)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={!focusSection}
onClick={() => {
if (focusSection) {
focusWorkspaceSection(focusSection.id);
}
}}
className="mt-3 min-h-10 border-amber-300/30 bg-amber-300/10 font-semibold text-amber-50 hover:bg-amber-300/20 hover:text-white"
aria-label={t("workspacePrioritiesOpenSection")}
>
{t("workspacePrioritiesOpenSection")}
</Button>
</section>
</div>

Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
"workspaceExportCueSheet": "Download tonight's cue sheet",
"workspaceExportChart": "Save a compact chart",
"workspaceExportHandoff": "Share a handoff file",
"workspaceExportNextHint": "Print the cue sheet or send the handoff before you leave.",
"workspacePrioritiesNextHint": "Start with {section} — that is tonight's first lock-in.",
"workspacePrioritiesOpenSection": "Open this section",
"workspaceStemsNextHint": "Stems are not ready yet. Start with tonight's cue sheet.",
"workspaceStemsDownloadCueSheet": "Download tonight's cue sheet",
"workspaceFocusFallback": "first pass",
"sectionRoadmapTitle": "Section Roadmap",
"sectionRoadmapScrollHint": "Scroll for more sections →",
"sectionGrooveLabel": "Groove",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
"workspaceExportCueSheet": "오늘 쓸 큐시트 받기",
"workspaceExportChart": "한눈에 보는 차트 저장",
"workspaceExportHandoff": "다음 연습용 핸드오프 보내기",
"workspaceExportNextHint": "나가기 전에 큐시트를 출력하거나 핸드오프를 보내세요.",
"workspacePrioritiesNextHint": "오늘은 {section}부터 잠그세요.",
"workspacePrioritiesOpenSection": "이 구간 열기",
"workspaceStemsNextHint": "스템은 아직 준비되지 않았습니다. 오늘 큐시트로 먼저 시작하세요.",
"workspaceStemsDownloadCueSheet": "오늘 쓸 큐시트 받기",
"workspaceFocusFallback": "첫 패스",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
Expand Down
Loading
Loading