Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6320116
feat(workspace): name tonight's first solo plan on the map
seonghobae Aug 25, 2026
f885423
test(workspace): cover proxy descriptor authority
seonghobae Aug 25, 2026
65a98cf
test(workspace): verify coverage config wiring
seonghobae Aug 25, 2026
89d994c
test(workspace): cover stable solo navigation scope
seonghobae Aug 25, 2026
e83705d
test(workspace): fail closed on proxy-backed solo data
seonghobae Aug 25, 2026
e10c4b9
fix(workspace): reject proxy-backed solo authority
seonghobae Aug 25, 2026
58f6a98
fix(workspace): navigate solo plan by stable section id
seonghobae Aug 25, 2026
46354e0
fix(workspace): expose stable roadmap section identity
seonghobae Aug 25, 2026
bf2e68c
fix(workspace): scope stable solo map navigation
seonghobae Aug 25, 2026
67a6d83
docs(storybook): add first solo plan states
seonghobae Aug 25, 2026
c5d95a6
test(workspace): reject accessor-backed solo graphs
seonghobae Aug 25, 2026
636caf0
fix(workspace): validate solo graph without invoking getters
seonghobae Aug 25, 2026
7a682f6
docs(workspace): record first solo trust and navigation contract
seonghobae Aug 25, 2026
7d3d6de
test(workspace): spy on solo-plan map target
seonghobae Aug 25, 2026
8bb9ba5
test(workspace): reject exotic solo-plan metadata
seonghobae Aug 25, 2026
4f9b374
fix(workspace): reject exotic solo-plan graph objects
seonghobae Aug 25, 2026
0c2ee78
docs(workspace): record solo-plan security test boundaries
seonghobae Aug 25, 2026
66eaeac
test(workspace): align solo particle navigation target
seonghobae Aug 25, 2026
ab886be
test(workspace): align reduced-motion solo target
seonghobae Aug 25, 2026
9292c9e
test(workspace): verify solo coverage config directly
seonghobae Aug 25, 2026
3c3ea64
test(workspace): reject cross-workspace solo navigation
seonghobae Aug 25, 2026
12ed1fb
fix(workspace): keep solo navigation inside local scope
seonghobae Aug 25, 2026
bb6aa70
refactor(workspace): remove obsolete solo section index
seonghobae Aug 25, 2026
8a998b6
test(workspace): mount solo navigation in owned scope
seonghobae Aug 25, 2026
97e5d61
test(workspace): scope Korean solo navigation fixture
seonghobae Aug 25, 2026
4be3ae3
test(workspace): scope reduced-motion solo fixture
seonghobae Aug 25, 2026
9852c69
Merge branch 'develop' into feat/workspace-first-solo-plan
seonghobae Aug 25, 2026
79ed0b9
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
7bb4008
fix(workspace): keep solo plan contract and navigation stable
seonghobae Aug 28, 2026
de8e242
fix(contract): align solo plan validation across boundaries
seonghobae Aug 29, 2026
ae62ae4
fix(contract): align Unicode plan whitespace
seonghobae Aug 29, 2026
22f9266
fix(solo): share Unicode plan validation
seonghobae Aug 29, 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 solo plan with the owning part when an active role is corroborated, the owned `soloPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes.
- 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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-25

## Brand source

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- The mounted workspace copy for tonight's first solo plan must name the owning part when corroborated, the owned `soloPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.

## Security source
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

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

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 solo plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. `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,21 @@
import { render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { expect, it } from "vitest";
import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout";

it("gives co-mounted solo-plan callouts distinct DOM identities", () => {
render(
<>
<FirstSoloPlanCallout song={createDemoRehearsalSong()} />
<FirstSoloPlanCallout song={createDemoRehearsalSong()} />
</>
);

const callouts = screen.getAllByRole("complementary", {
name: "Tonight's first solo plan"
});
const ids = callouts.map((callout) => callout.id);

expect(ids.every((id) => id.length > 0)).toBe(true);
expect(new Set(ids).size).toBe(callouts.length);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout";

describe("FirstSoloPlanCallout resolver reuse", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("does not rescan role metadata when a parent rerenders the same song object", () => {
const song = createDemoRehearsalSong();
const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!;
const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor");

const { rerender } = render(<FirstSoloPlanCallout song={song} />);
const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;
expect(firstScanCount).toBeGreaterThan(0);

rerender(<FirstSoloPlanCallout song={song} />);
const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length;

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

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

it("keeps vowel-ending role names particle-safe before and after the solo action", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
seed.roles = [
{
...seed.roles[2]!,
id: "piano",
name: "피아노",
rehearsalPriority: "high",
soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."
}
];
seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];

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.sectionId = seed.id;
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: vi.fn()
});
grid.appendChild(target);
document.body.appendChild(grid);

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

expect(screen.getByText("0:10 벌스에서 피아노 파트의 솔로 계획이 있습니다.")).toBeTruthy();
expect(screen.queryByText(/피아노이/)).toBeNull();
expect(screen.queryByText(/피아노가/)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 솔로 열기" }));

expect(screen.getByText("0:10에서 피아노 파트의 솔로를 맞춘 다음 합주를 시작하세요.")).toBeTruthy();

Check failure on line 47 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx

View workflow job for this annotation

GitHub Actions / ci / build-and-test

src/features/workspace/FirstSoloPlanCallout.particle.test.tsx > FirstSoloPlanCallout Korean role copy > keeps vowel-ending role names particle-safe before and after the solo action

TestingLibraryElementError: Unable to find an element with the text: 0:10에서 피아노 파트의 솔로를 맞춘 다음 합주를 시작하세요.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div aria-label="Scrollable song structure timeline" data-testid="song-structure-grid" role="region" > <div data-section-id="verse-1" /> </div> <div> <aside aria-label="오늘 첫 솔로 계획" class="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4" id="workspace-surface-solo-plan-_r_0_" > <p class="text-xs font-black uppercase tracking-[0.24em] text-cyan-200" > 오늘 첫 솔로 계획 </p> <p class="mt-2 text-sm leading-6 text-slate-300" > 0:10 벌스에서 피아노 파트의 솔로 계획이 있습니다. </p> <p class="mt-1 text-sm leading-6 text-slate-400" > Hold the verse solo; everyone else drops to a two-bar pad so the run can land. </p> <button class="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-primary/80 disabled:hover:bg-primary aria-disabled:hover:bg-primary h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" data-slot="button" tabindex="0" type="button" > 0:10 피아노 솔로 열기 </button> </aside> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/features/workspace/FirstSoloPlanCallout.particle.test.tsx:47:19

Check failure on line 47 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx

View workflow job for this annotation

GitHub Actions / release-preflight

src/features/workspace/FirstSoloPlanCallout.particle.test.tsx > FirstSoloPlanCallout Korean role copy > keeps vowel-ending role names particle-safe before and after the solo action

TestingLibraryElementError: Unable to find an element with the text: 0:10에서 피아노 파트의 솔로를 맞춘 다음 합주를 시작하세요.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div aria-label="Scrollable song structure timeline" data-testid="song-structure-grid" role="region" > <div data-section-id="verse-1" /> </div> <div> <aside aria-label="오늘 첫 솔로 계획" class="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4" id="workspace-surface-solo-plan-_r_0_" > <p class="text-xs font-black uppercase tracking-[0.24em] text-cyan-200" > 오늘 첫 솔로 계획 </p> <p class="mt-2 text-sm leading-6 text-slate-300" > 0:10 벌스에서 피아노 파트의 솔로 계획이 있습니다. </p> <p class="mt-1 text-sm leading-6 text-slate-400" > Hold the verse solo; everyone else drops to a two-bar pad so the run can land. </p> <button class="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 hover:bg-primary/80 disabled:hover:bg-primary aria-disabled:hover:bg-primary h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" data-slot="button" tabindex="0" type="button" > 0:10 피아노 솔로 열기 </button> </aside> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/features/workspace/FirstSoloPlanCallout.particle.test.tsx:47:19
expect(screen.queryByText(/피아노과/)).toBeNull();

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

describe("FirstSoloPlanCallout 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 song = createDemoRehearsalSong();
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.sectionId = song.sections[0]!.id;
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstSoloPlanCallout song={song} />);
fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

Check failure on line 40 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx

View workflow job for this annotation

GitHub Actions / ci / build-and-test

src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx > FirstSoloPlanCallout reduced motion > scrolls immediately when the operating system requests reduced motion

AssertionError: expected "vi.fn()" to be called with arguments: [ { block: 'nearest', …(1) } ] Number of calls: 0 ❯ src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx:40:28

Check failure on line 40 in apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx

View workflow job for this annotation

GitHub Actions / release-preflight

src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx > FirstSoloPlanCallout reduced motion > scrolls immediately when the operating system requests reduced motion

AssertionError: expected "vi.fn()" to be called with arguments: [ { block: 'nearest', …(1) } ] Number of calls: 0 ❯ src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx:40:28

grid.remove();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout";
import { SectionRoadmap } from "./SectionRoadmap";

function withoutSoloPlans(): RehearsalSong {
const song = createDemoRehearsalSong();
return {
...song,
sections: song.sections.map((section) => ({
...section,
roles: section.roles.map((role) => ({ ...role, soloPlan: "" }))
}))
};
}

function StorySurface({ song }: { song: RehearsalSong }) {
return (
<div className="min-h-screen bg-slate-950 p-6 text-slate-100">
<div className="mx-auto max-w-5xl space-y-6">
<FirstSoloPlanCallout song={song} />
<div data-testid="song-structure-grid" aria-hidden="true" className="hidden" />
<SectionRoadmap song={song} activeRole={null} />
</div>
</div>
);
}

const meta = {
title: "Workspace/First Solo Plan Callout",
component: FirstSoloPlanCallout,
parameters: { layout: "fullscreen" },
render: ({ song }) => <StorySurface song={song} />
} satisfies Meta<typeof FirstSoloPlanCallout>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Available: Story = {
args: { song: createDemoRehearsalSong() }
};

export const Unavailable: Story = {
args: { song: withoutSoloPlans() }
};
Loading
Loading