Skip to content
Closed
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 a part is selected, Loop must name tonight's practice window (section + time, tempo when known) and open that section. Do not leave Loop as coming soon while Play stem stays unavailable.
- 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.
- After a part is selected, the stem-player Loop control must name tonight's practice window and open that section on the roadmap. Play stem stays unavailable until Stem Lab exists.

## Analysis target model

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- After a part is selected, Loop names tonight's practice window (section, time range, tempo when known) and opens that section. Timeline cells and section cards do the same instead of sitting as dead descriptions; section navigation also honors the system reduced-motion preference instead of forcing smooth scrolling.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down Expand Up @@ -65,4 +66,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
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 a part is selected, Loop must name tonight's practice window and open that section. Do not leave that control as a coming-soon dead end.

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
12 changes: 12 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,16 @@ describe("SectionRoadmap", () => {

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

it("names a focusable practice window on each section card", () => {
setNavigatorLanguage("en-US");
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");
expect(screen.getByRole("button", { name: "Practice verse 0:10–0:30" })).toBeTruthy();
});
});
41 changes: 38 additions & 3 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucide-react";
import { Button } from "@/components/ui/button";

interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
}

/** Documented. */
function formatTimelineTime(totalSeconds: number): string {
const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0;
const minutes = Math.floor(safeSeconds / 60);
const seconds = Math.floor(safeSeconds % 60)
.toString()
.padStart(2, "0");
return `${minutes}:${seconds}`;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
Expand Down Expand Up @@ -103,10 +114,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
{song.sections.map((section) => (
{song.sections.map((section) => {
const window = `${formatTimelineTime(section.timeRange.start)}–${formatTimelineTime(section.timeRange.end)}`;
const practiceLabel = t("workspacePracticeWindowAction")
.replaceAll("{section}", section.label)
.replaceAll("{window}", window);
return (
<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 All @@ -119,6 +137,22 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
<span className="mr-2 text-[0.65rem] font-bold uppercase tracking-wider text-slate-400">{t("sectionGrooveLabel")}</span>
{section.groove}
</div>
<p className="mt-2 text-xs font-semibold uppercase tracking-[0.16em] text-cyan-200">{window}</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 min-h-10 w-full border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
aria-label={practiceLabel}
onClick={() => {
const node = document.getElementById(`workspace-section-${section.id}`);
if (node instanceof HTMLElement && typeof node.focus === "function") {
node.focus();
}
}}
>
{practiceLabel}
</Button>
</CardHeader>

<CardContent className="p-4 space-y-4">
Expand Down Expand Up @@ -212,7 +246,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
))}
</CardContent>
</Card>
))}
);
})}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 originalMatchMedia = window.matchMedia;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

function setNavigatorLanguage(language: string): void {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("Workspace reduced-motion navigation", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: originalMatchMedia
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView
});
vi.restoreAllMocks();
});

it("uses non-animated scrolling when reduced motion is requested", () => {
setNavigatorLanguage("en-US");
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});

render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("button", { name: "Open verse 0:10–0:30" }));

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "auto",
block: "nearest",
inline: "center"
});
});
});
59 changes: 59 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,63 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});

it("names tonight's practice window after a part is selected", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByText(/Open verse 0:10–0:30 at 120 BPM and lock the entrance first/i)).toBeTruthy();
const loopButton = screen.getByRole("button", { name: "Loop verse 0:10–0:30 · 120 BPM" });
expect((loopButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(loopButton);

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

it("opens the named practice window from the song-structure timeline", () => {
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 verse 0:10–0:30" }));

expect(document.activeElement).toBe(document.getElementById("workspace-section-verse-1"));
});

it("disables the loop action 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.queryByRole("button", { name: /Loop /i })).toBeNull();
expect(screen.getByTestId("song-structure-grid")).toBeTruthy();
});

it("localizes the practice-window next action", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByRole("button", { name: "verse 0:10–0:30 · 120 BPM 반복" })).toBeTruthy();
expect(screen.getByRole("button", { name: "verse 0:10–0:30 연습" })).toBeTruthy();
expect(screen.getByRole("button", { name: "verse 0:10–0:30 열기" })).toBeTruthy();
});
});
Loading
Loading