Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5e55876
feat(workspace): arm tonight's first setup on the role strip
seonghobae Aug 18, 2026
25bd74f
fix(workspace): preserve setup copy and transposition detail
seonghobae Aug 18, 2026
1db3ff0
test(workspace): lock review regressions
seonghobae Aug 18, 2026
12b1c4a
fix(workspace): make groove map role-aware and accessible
seonghobae Aug 18, 2026
ef4b354
fix(workspace): require actionable start evidence
seonghobae Aug 18, 2026
1c841b0
fix(workspace): explain missing start evidence
seonghobae Aug 18, 2026
6f686fe
fix(workspace): localize missing start evidence
seonghobae Aug 18, 2026
6d838ca
test(workspace): assert native setup disable
seonghobae Aug 18, 2026
098f634
docs(architecture): refresh current guidance date
seonghobae Aug 18, 2026
d27bff6
docs(design): keep groove-map contract code-current
seonghobae Aug 18, 2026
fd36651
docs(changelog): record workspace review repairs
seonghobae Aug 18, 2026
184ad06
test(workspace): expect role-specific groove map label
seonghobae Aug 18, 2026
b06be3f
test(workspace): keep setup placeholders literal
seonghobae Aug 18, 2026
0ad6315
fix(workspace): interpolate setup copy once
seonghobae Aug 18, 2026
16abeb1
test(security): forbid dynamic regex copy interpolation
seonghobae Aug 18, 2026
512c860
fix(security): use fixed copy interpolation pattern
seonghobae Aug 18, 2026
61aa51f
test(workspace): make setup regressions portable
seonghobae Aug 18, 2026
c49d0f3
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 25, 2026
d14634b
docs(changelog): remove duplicated test entry
seonghobae Aug 26, 2026
a6bc38d
test(workspace): require visible setup label in accessible name
seonghobae Aug 26, 2026
4850852
fix(a11y): include visible setup action in accessible name
seonghobae Aug 26, 2026
8d1fdd2
fix(a11y): align Korean setup accessible names
seonghobae Aug 26, 2026
1cc2699
test(a11y): align setup accessible-name oracle
seonghobae Aug 26, 2026
4e252a4
test(changelog): preserve shipped security fix classification
seonghobae Aug 26, 2026
cce6ff4
fix(changelog): preserve protected security history
seonghobae Aug 26, 2026
fbb6973
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
b6bcecb
fix(workspace): restore setup test contract
seonghobae Aug 28, 2026
ec61228
test(workspace): preserve visible stem control names
seonghobae Sep 7, 2026
30ebfcc
fix(a11y): preserve visible stem control names
seonghobae Sep 7, 2026
ad2daa5
test(workspace): align disabled control contract
seonghobae Sep 7, 2026
9721802
Merge protected develop into workspace accessibility repair
seonghobae Sep 7, 2026
1c3248a
test(workspace): reject malformed setup ranges
seonghobae Sep 7, 2026
87e8640
fix(workspace): validate setup range evidence
seonghobae Sep 7, 2026
9bd8e1b
test(release): preserve semantic setup boundaries
seonghobae Sep 7, 2026
94f1c00
test(i18n): cover workspace rehearsal controls
seonghobae Sep 7, 2026
0044cd5
fix(i18n): localize rehearsal controls
seonghobae Sep 7, 2026
39a12a9
docs(workspace): define validated range fallback
seonghobae Sep 7, 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.
- After a part is selected, the role strip must name tonight's setup from `setupNote` / transposition / simplification and point at the first entrance. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here.
- 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,6 +1,6 @@
# ARCHITECTURE.md

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

## Brand source

Expand Down 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 role-strip setup must arm tonight's `setupNote` (then transposition / simplification) and name the first analyzed entrance on the groove map. Isolation playback stays out of this lane.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Analysis target model

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

### Added

- The ready workspace can set up tonight's selected part from the analyzed setup cue and name the first entrance on the groove map, instead of leaving `Transcribe Bass` inert.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Fixed

- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down Expand Up @@ -65,4 +70,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, the role-strip setup control must name tonight's setup cue and the first entrance. Do not leave `Transcribe Bass` as a no-op.

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
39 changes: 26 additions & 13 deletions apps/desktop/src/features/workspace/GrooveMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import { Loader2 } from "lucide-react";

const EMPTY_NOTES: TranscriptionNote[] = [];

/** Documented. */
/** Inputs for the selected role's rehearsal groove map. */
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
entranceOnset?: number;
roleName: string;
}

/** Documented. */
function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
/** Render the selected role's transcription and optional first-entrance emphasis. */
function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;

// Find max offset to determine timeline width
Expand All @@ -36,6 +38,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
return map;
}, [uniquePitches]);

const entranceIndex = useMemo(() => {
if (entranceOnset === undefined) {
return -1;
}
return renderedNotes.findIndex((note) => note.onset === entranceOnset);
}, [entranceOnset, renderedNotes]);

if (isLoading) {
return (
<div
Expand All @@ -44,7 +53,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
>
<span className="flex items-center font-medium text-teal-100">
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Checking the bass line... 45%
Checking the {roleName} line... 45%
</span>
<Button variant="outline" size="sm" className="border-teal-300/20 bg-teal-300/10 text-teal-100 hover:bg-teal-300/20 hover:text-white">
Cancel
Expand All @@ -55,10 +64,8 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {

if (renderedNotes.length === 0) {
return (
<div
className="mt-4 rounded-lg border border-dashed border-cyan-200/15 bg-slate-950/60 p-6 text-center text-sm text-slate-400"
>
No bass line transcription yet. Use it when you want to check the groove before rehearsal.
<div className="mt-4 rounded-lg border border-dashed border-cyan-200/15 bg-slate-950/60 p-6 text-center text-sm text-slate-300">
No {roleName} transcription yet. Use it when you want to check the groove before rehearsal.
Comment thread
seonghobae marked this conversation as resolved.
Outdated
</div>
);
}
Expand All @@ -68,7 +75,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
role="region"
tabIndex={0}
aria-label="Bass transcription groove map"
aria-label={`${roleName} transcription groove map`}
>
<div className="sr-only">
Transcription complete. {renderedNotes.length} notes analyzed.
Expand All @@ -95,20 +102,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const leftPercent = (note.onset / maxTime) * 100;
const widthPercent = ((note.offset - note.onset) / maxTime) * 100;
const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`;
const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset;

return (
<div
key={index}
className="absolute h-6 rounded bg-gradient-to-r from-teal-300 via-cyan-300 to-violet-300 shadow-[0_0_18px_rgba(94,234,212,0.28)]"
id={isEntrance && index === entranceIndex ? "workspace-groove-entrance" : undefined}
Comment thread
seonghobae marked this conversation as resolved.
className={`absolute h-6 rounded shadow-[0_0_18px_rgba(94,234,212,0.28)] ${
isEntrance
? "bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300 ring-2 ring-amber-200"
: "bg-gradient-to-r from-teal-300 via-cyan-300 to-violet-300"
}`}
style={{
top: `${pitchIndex * 40 + 8}px`,
left: `${leftPercent}%`,
width: `${widthPercent}%`
}}
title={noteLabel}
title={isEntrance ? `Tonight's entrance · ${noteLabel}` : noteLabel}
>
<span className="sr-only">
{noteLabel}
{isEntrance ? `Tonight's entrance. ${noteLabel}` : noteLabel}
</span>
</div>
);
Expand All @@ -120,4 +133,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {

const GrooveMap = memo(GrooveMapComponent);

export { GrooveMap };
export { GrooveMap };
117 changes: 117 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.review.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
import { GrooveMap } from "./GrooveMap";
import { Workspace } from "./Workspace";

/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */
function replaceRole(song: ReturnType<typeof createDemoRehearsalSong>, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) {
song.sections = song.sections.map((section) => ({
...section,
roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role))
}));
}

describe("Workspace review regressions", () => {
it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
expect(source).not.toContain("new RegExp(");
});

it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
render(
<GrooveMap
roleName="Lead Guitar"
entranceOnset={1}
notes={[
{ pitch: "E4", onset: 1, offset: 1.5, velocity: 0.8 },
{ pitch: "G4", onset: 1, offset: 1.5, velocity: 0.75 }
]}
/>
);

const region = screen.getByRole("region", { name: "Lead Guitar transcription groove map" });
expect(region.className).toContain("focus-visible:ring-2");
expect(document.querySelectorAll("#workspace-groove-entrance")).toHaveLength(1);
expect(screen.getAllByTitle(/Tonight's entrance/)).toHaveLength(2);
});

it("uses the selected role name in groove-map empty and loading copy", () => {
const { rerender } = render(<GrooveMap roleName="Lead Guitar" notes={[]} />);
expect(screen.getByText("No Lead Guitar transcription yet. Use it when you want to check the groove before rehearsal.")).toBeTruthy();

rerender(<GrooveMap roleName="Lead Guitar" notes={[]} isLoading />);
expect(screen.getByText("Checking the Lead Guitar line... 45%")).toBeTruthy();
});

it("keeps range-backed setup available when no exact first note exists", () => {
const song = createDemoRehearsalSong();
const roleId = song.sections[0]!.roles[0]!.id;
replaceRole(song, roleId, (role) => ({
...role,
setupNote: "Tune down a whole step.",
transcription: undefined,
range: {
...role.range,
lowestNote: "C#2",
highestNote: "E3"
}
}));

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));

const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i });
expect(setupButton).toBeEnabled();
});

it("keeps placeholder-looking role names literal in setup copy", () => {
const song = createDemoRehearsalSong();
const roleId = song.sections[0]!.roles[0]!.id;
replaceRole(song, roleId, (role) => ({
...role,
name: "{low}",
setupNote: "Tune down a whole step.",
transcription: undefined,
range: {
...role.range,
lowestNote: "C#2",
highestNote: "E3"
}
}));

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

expect(
screen.getByRole("button", {
name: "Set up {low}: Tune down a whole step. Then start in C#2–E3 on tonight's map"
})
).toBeEnabled();
});

it("natively disables setup when a cue has neither an entrance nor a playable range", () => {
const song = createDemoRehearsalSong();
const roleId = song.sections[0]!.roles[0]!.id;
replaceRole(song, roleId, (role) => ({
...role,
setupNote: "Tune down a whole step.",
transcription: undefined,
range: {
...role.range,
lowestNote: " ",
highestNote: " "
}
}));

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));

const setupButton = screen.getByRole("button", {
name: "No first entrance or playable range yet. Stay on tonight's map."
});
expect(setupButton).toBeDisabled();
});
});
Loading
Loading