Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b2aa330
feat(workspace): guide tonight's first tag on the rehearsal map
seonghobae Aug 23, 2026
d17a93b
test(workspace): reject throwing tag metadata accessors
seonghobae Aug 23, 2026
30552b6
test(workspace): reject cross-surface tag target
seonghobae Aug 23, 2026
e731168
fix(workspace): contain hostile tag metadata accessors
seonghobae Aug 23, 2026
bad3e4e
fix(workspace): scope first-tag navigation to map renderer
seonghobae Aug 23, 2026
d2d71b3
fix(workspace): anchor first-tag navigation to structure grid
seonghobae Aug 23, 2026
dcc13db
test(workspace): model scoped tag renderer in reduced-motion fixture
seonghobae Aug 23, 2026
22acea8
test(workspace): model scoped tag renderer in particle fixture
seonghobae Aug 23, 2026
7d3ffa9
fix(workspace): restore canonical workspace surface
seonghobae Aug 23, 2026
93f6ae3
fix(workspace): bind first-tag navigation to rendered structure root
seonghobae Aug 23, 2026
7dbdd01
test(workspace): scope first-tag fixture to rendered structure root
seonghobae Aug 23, 2026
36c1962
test(workspace): mirror rendered structure root for reduced motion
seonghobae Aug 23, 2026
9a96a02
test(workspace): mirror rendered structure root for Korean copy
seonghobae Aug 23, 2026
047b3d9
docs(i18n): describe exported translation helpers
seonghobae Aug 23, 2026
523ac83
test(workspace): reject accessor-backed tag identity
seonghobae Aug 23, 2026
9ac1ad5
fix(workspace): require data-backed tag metadata
seonghobae Aug 23, 2026
4e73ea3
docs(changelog): record first-tag metadata hardening
seonghobae Aug 23, 2026
47c4541
test(workspace): contain hostile song identity accessor
seonghobae Aug 23, 2026
041f702
fix(workspace): contain hostile song identity metadata
seonghobae Aug 23, 2026
98b8605
test(coverage): include first-tag production files
seonghobae Aug 23, 2026
8dc1528
docs(changelog): restore released 0.1.0 history
seonghobae Aug 23, 2026
40556d1
test(workspace): require stable first-tag renderer hook
seonghobae Aug 23, 2026
978b5f8
test(workspace): target first-tag navigation by semantic region
seonghobae Aug 23, 2026
d342f7b
fix(workspace): navigate first tag through semantic timeline
seonghobae Aug 23, 2026
afef071
test(workspace): reset first-tag state across hostile song ids
seonghobae Aug 23, 2026
ce69ca9
fix(workspace): key first-tag state by song instance
seonghobae Aug 23, 2026
7969484
fix(workspace): narrow opened tag state
seonghobae Aug 23, 2026
b8d70dd
test(workspace): mount first-tag timeline region
seonghobae Aug 23, 2026
d054d9a
test(workspace): mirror first-tag renderer contract
seonghobae Aug 23, 2026
b567215
test(workspace): cover localized tag navigation target
seonghobae Aug 23, 2026
86c80bc
fix(workspace): decouple tag navigation from accessible copy
seonghobae Aug 23, 2026
6b83256
test(workspace): cover missing matchMedia fallback
seonghobae Aug 23, 2026
c52c0a8
Merge branch 'develop' into feat/workspace-first-tag
seonghobae Aug 25, 2026
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.
- Name tonight's first labeled tag with the holding part when an active role is corroborated, the labeled last line, and the time so the next action is obvious.
- 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 @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- The mounted workspace copy for tonight's first labeled tag must name the holding part when corroborated, the labeled last line, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a tag from outro, verse, chorus, or the last unlabeled section.

## Security source

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

- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- Name tonight's first labeled tag in the mounted rehearsal workspace so the room can catch the last line together; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). `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 mounted workspace names tonight's first labeled tag and opens the matching rendered map section. Do not invent a last line from outro, verse, or an unlabeled ending. `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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstTagCallout } from "./FirstTagCallout";

describe("FirstTagCallout Korean role copy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("keeps vowel-ending dynamic role names particle-safe before and after the tag action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
const tag = structuredClone(seed);
tag.id = "tag-particle";
tag.label = "tag";
tag.timeRange = { start: 200, end: 208 };
tag.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }];
tag.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];
song.sections = [tag];

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
grid.setAttribute("role", "region");
grid.setAttribute("aria-label", "Scrollable song structure timeline");
const target = document.createElement("div");
target.dataset.sectionIndex = "0";
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
document.body.appendChild(grid);

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

expect(screen.getByText("3:20 태그에서 피아노 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "3:20 피아노 태그 위치 열기" }));

expect(screen.getByText("3:20에서 피아노 파트와 함께 마지막 한 줄을 잡으세요. 같이 끝내세요.")).toBeTruthy();
expect(screen.queryByText(/피아노과/)).toBeNull();
Comment thread
seonghobae marked this conversation as resolved.

grid.remove();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstTagCallout } from "./FirstTagCallout";

function songWithTag() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const tag = structuredClone(verse);
tag.id = "tag-1";
tag.label = "tag";
tag.timeRange = { start: 200, end: 208 };
tag.roles = [
{
...verse.roles[0]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
tag.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, tag];
return song;
}

function appendTagTarget() {
const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
grid.setAttribute("role", "region");
grid.setAttribute("aria-label", "Scrollable song structure timeline");
const first = document.createElement("div");
first.dataset.sectionIndex = "0";
const target = document.createElement("div");
target.dataset.sectionIndex = "1";
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(first);
grid.appendChild(target);
document.body.appendChild(grid);
return { grid, scrollIntoView };
}

describe("FirstTagCallout reduced motion", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("scrolls immediately when the operating system requests reduced motion", () => {
vi.stubGlobal("matchMedia", (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()
}));
const { grid, scrollIntoView } = appendTagTarget();

render(<FirstTagCallout song={songWithTag()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

grid.remove();
});

it("uses smooth scrolling when matchMedia is unavailable", () => {
vi.stubGlobal("matchMedia", undefined);
const { grid, scrollIntoView } = appendTagTarget();

render(<FirstTagCallout song={songWithTag()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

grid.remove();
});
});
225 changes: 225 additions & 0 deletions apps/desktop/src/features/workspace/FirstTagCallout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstTagCallout } from "./FirstTagCallout";

function songWithTag() {
const song = createDemoRehearsalSong();
const verse = song.sections[0]!;
const tag = structuredClone(verse);
tag.id = "tag-1";
tag.label = "tag";
tag.timeRange = { start: 200, end: 208 };
tag.roles = [
{
...verse.roles[0]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
tag.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [verse, tag];
return song;
}

function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") {
const timeline = document.createElement("div");
timeline.setAttribute("role", "region");
timeline.setAttribute("aria-label", ariaLabel);
const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const first = document.createElement("div");
first.dataset.sectionIndex = "0";
const unrelatedSibling = document.createElement("div");
const target = document.createElement("div");
target.dataset.sectionIndex = "1";
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(first);
grid.appendChild(unrelatedSibling);
grid.appendChild(target);
timeline.appendChild(grid);
document.body.appendChild(timeline);
return { grid: timeline, scrollIntoView };
}

describe("FirstTagCallout", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("contains a malformed runtime song root instead of crashing the callout", () => {
render(<FirstTagCallout song={null as unknown as RehearsalSong} />);

expect(
screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.")
).toBeTruthy();
});

it("contains a hostile song identity accessor instead of crashing the callout", () => {
const song = songWithTag();
Object.defineProperty(song, "id", {
configurable: true,
enumerable: true,
get() {
throw new Error("hostile song id getter");
}
});

expect(() => render(<FirstTagCallout song={song} />)).not.toThrow();
expect(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })).toBeTruthy();
});

it("resets armed guidance when accessor-id songs change with the same tag signature", () => {
const firstSong = songWithTag();
const nextSong = songWithTag();
for (const song of [firstSong, nextSong]) {
Object.defineProperty(song, "id", {
configurable: true,
enumerable: true,
get() {
throw new Error("hostile song id getter");
}
});
}
const { grid } = appendSongStructureTarget();
const { rerender } = render(<FirstTagCallout song={firstSong} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));
expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy();

rerender(<FirstTagCallout song={nextSong} />);

expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy();
expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull();

grid.remove();
});

it("names the first tag as map navigation, scrolls to its rendered section, and arms that action", () => {
const { grid, scrollIntoView } = appendSongStructureTarget();

render(<FirstTagCallout song={songWithTag()} />);

const action = screen.getByRole("button", {
name: "Open Lead Vocal tag at 3:20"
});
expect(action).toBeTruthy();
fireEvent.click(action);
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy();

grid.remove();
});

it("keeps map navigation stable when the renderer accessible name is localized", () => {
const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인");

render(<FirstTagCallout song={songWithTag()} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));

expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy();

grid.remove();
});

it("does not claim map navigation completed when the rendered section target is missing", () => {
render(<FirstTagCallout song={songWithTag()} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));

expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy();
expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull();
});

it("navigates by renderer-owned section position instead of untrusted analysis ids", () => {
const song = songWithTag();
song.sections[1]!.id = "analysis section / duplicate";
const { grid, scrollIntoView } = appendSongStructureTarget();

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

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

grid.remove();
});

it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => {
const decoy = document.createElement("div");
decoy.dataset.sectionIndex = "1";
const decoyScrollIntoView = vi.fn();
Object.defineProperty(decoy, "scrollIntoView", {
configurable: true,
value: decoyScrollIntoView
});
document.body.appendChild(decoy);
const { grid, scrollIntoView } = appendSongStructureTarget();

render(<FirstTagCallout song={songWithTag()} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));

expect(decoyScrollIntoView).not.toHaveBeenCalled();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

decoy.remove();
grid.remove();
});

it("shows fresh guidance when the first tag changes or returns later", () => {
const initialSong = songWithTag();
const { grid } = appendSongStructureTarget();
const { rerender } = render(<FirstTagCallout song={initialSong} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" }));
expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy();

const nextSong = songWithTag();
nextSong.id = "next-song";
nextSong.sections[1]!.timeRange = { start: 220, end: 228 };
rerender(<FirstTagCallout song={nextSong} />);
expect(screen.getByText("Lead Vocal holds the tag at 3:40.")).toBeTruthy();

grid.remove();
});

it("keeps an unavailable tag guidance-only", () => {
render(<FirstTagCallout song={createDemoRehearsalSong()} />);
expect(screen.queryByRole("button")).toBeNull();
expect(
screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.")
).toBeTruthy();
});

it("names a band-wide last line when no part holds the tag", () => {
const song = songWithTag();
song.sections[1]!.partGraph[0]!.is_active = false;
render(<FirstTagCallout song={song} />);
expect(screen.getByRole("button", { name: "Open the first tag at 3:20" })).toBeTruthy();
expect(screen.getByText("The band catches the tag at 3:20.")).toBeTruthy();
});

it("localizes the tag form label instead of exposing its raw enum in Korean copy", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = songWithTag();
song.sections[1]!.roles[0]!.name = "리드 보컬";

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

expect(screen.getByText("3:20 태그에서 리드 보컬 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy();
expect(screen.queryByText(/tag에서/)).toBeNull();
});
});
Loading
Loading