Skip to content
Draft
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}
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