Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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, 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
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### 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.
- 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 All @@ -15,6 +16,7 @@
### Fixed

- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
- 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

Expand Down Expand Up @@ -74,4 +76,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`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 on lines 64 to +68

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: GrooveMap copy stays English under Korean locale

The loading, empty, and aria-label strings in GrooveMapComponent are hardcoded English and bypass the translator, unlike the rest of the workspace. Korean users see English. Pre-existing, but this change edits these strings and localizes the adjacent Play stem / Solo buttons while leaving the Loop section button English, widening the inconsistency.

(Refers to this code)

Devin Review

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

</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 on lines +105 to +110

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: Single entrance anchor for simultaneous notes

GrooveMap.tsx:110 gates the workspace-groove-entrance id on index === entranceIndex, so simultaneous notes sharing the earliest onset all get entrance styling but only one gets the DOM id. Correct because activeRoleTranscription is onset-sorted upstream.

Open in Devin Review

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

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}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</span>
</div>
);
Expand All @@ -120,4 +133,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {

const GrooveMap = memo(GrooveMapComponent);

export { GrooveMap };
export { GrooveMap };
120 changes: 120 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,120 @@
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();
const visibleLabel = setupButton.textContent?.trim() ?? "";
expect(visibleLabel).not.toBe("");
expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel);
});

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} · then start in C#2–E3. Setup: Tune down a whole step. Use 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