Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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, the song-structure timeline and role strip must start tonight's first loop on the map. Do not leave `Loop section` / `Play stem` as "coming soon" dead ends, and do not invent Stem Lab isolation here.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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 timeline chips and the role-strip loop control must arm tonight's first section window and focus the matching Section Roadmap card. Isolation playback stays out of this 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

- The ready workspace can loop tonight's first section from the timeline or the role strip and jump to that Section Roadmap card, instead of leaving `Loop section` as coming soon.
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
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, the song-structure timeline and role-strip loop control must start tonight's first map loop. Do not leave those buttons as "coming soon".

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 @@ -106,4 +106,16 @@ describe("SectionRoadmap", () => {

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

it("keeps focus target ids renderer-owned for arbitrary analysis section ids", () => {
const song = createDemoRehearsalSong();
song.sections[0].id = " verse 1 ";

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

const card = document.getElementById("workspace-section-card-0");
expect(card).toBeTruthy();
expect(card?.getAttribute("tabindex")).toBe("-1");
expect(card?.id).not.toContain(song.sections[0].id);
});
});
17 changes: 12 additions & 5 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
loopedSectionIndex?: number | null;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
Expand Down Expand Up @@ -104,11 +105,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
tabIndex={0}
aria-labelledby={sectionRoadmapTitleId}
>
{song.sections.map((section) => (
{song.sections.map((section, sectionIndex) => (
<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)] ${
section.confidence.level === "low" ? "border-rose-300/30 bg-rose-950/30" : "border-white/10 bg-slate-950/80"
key={`${section.id}-${sectionIndex}`}
id={`workspace-section-card-${sectionIndex}`}
tabIndex={-1}
Comment on lines +108 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Index-based card ids resolve the duplicate-id focus fix

Card ids and focus routing now key on renderer position rather than analysis section.id. Both SongStructure chips and SectionRoadmap cards iterate song.sections in the same order, so duplicate or whitespace-bearing analysis ids no longer misroute focus or collide.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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 ${
loopedSectionIndex === sectionIndex
? "border-cyan-300/50 bg-cyan-950/40 ring-2 ring-cyan-300/70"
: section.confidence.level === "low"
? "border-rose-300/30 bg-rose-950/30"
: "border-white/10 bg-slate-950/80"
}`}
>
<CardHeader className="border-b border-white/10 bg-white/[0.04] p-5 pb-4">
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 loop 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 roadmap 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: "Loop verse from 0:10 to 0:30" }));

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "auto",
block: "nearest",
inline: "center"
});
});
});
71 changes: 71 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export";
const originalLanguage = navigator.language;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
Expand All @@ -28,6 +29,7 @@ describe("Workspace", () => {
configurable: true,
value: originalRevokeObjectUrl
});
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});

it("updates practice progress immutably through onSongUpdate", () => {
Expand Down Expand Up @@ -326,4 +328,73 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});

it("loops tonight's first section from the timeline and focuses the roadmap card", () => {
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

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

fireEvent.click(screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30" }));

expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy();
expect(document.activeElement?.id).toBe("workspace-section-card-0");
expect(scrollIntoView).toHaveBeenCalled();
});

it("focuses the selected renderer position even when analysis section ids are duplicated", () => {
const song = createDemoRehearsalSong();
const firstSection = song.sections[0]!;
song.sections = [
firstSection,
{
...firstSection,
id: firstSection.id,
label: "chorus",
timeRange: {
start: 30,
end: 50
}
}
];
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

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

const loopButtons = screen.getAllByRole("button", { name: /Loop .* from .* to .*/ });
expect(loopButtons).toHaveLength(2);
fireEvent.click(loopButtons[1]!);

expect(document.activeElement?.id).toBe("workspace-section-card-1");
expect(scrollIntoView).toHaveBeenCalled();
});

it("names the first loop from the selected role strip instead of coming soon", () => {
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

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

const loopButton = screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30 on tonight's map" });
expect(loopButton).toBeTruthy();
expect((loopButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(loopButton);

expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy();
expect(document.activeElement?.id).toBe("workspace-section-card-0");
expect(screen.getByRole("button", { name: "Isolation is not ready. Loop tonight's section on the map." })).toBeTruthy();
});

it("localizes the first map loop action without broken Korean particles", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();

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

expect(screen.getByRole("button", { name: "verse 구간을 0:10부터 0:30까지 루프" })).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it } from "vitest";
import { Workspace } from "./Workspace";

const originalLanguageDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "language");

afterEach(() => {
if (originalLanguageDescriptor) {
Object.defineProperty(window.navigator, "language", originalLanguageDescriptor);
} else {
Reflect.deleteProperty(window.navigator, "language");
}
});

function useKoreanLocale(): void {
Object.defineProperty(window.navigator, "language", {
configurable: true,
value: "ko-KR"
});
}

describe("Workspace timeline region localization", () => {
it("uses localized accessible copy for the scrollable song-structure timeline", () => {
useKoreanLocale();

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

expect(screen.getByRole("region", { name: "스크롤 가능한 곡 구조 타임라인" })).toBeTruthy();
});
});
Loading
Loading