Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 earliest analyzed note, or a validated playable range when no exact note exists. 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
9 changes: 9 additions & 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 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,7 +16,15 @@

### Fixed

- Keep disabled Stem Player controls discoverable by their visible labels for
assistive technology and speech input while retaining the translated
unavailable reason in each accessible name and tooltip.
- Reject sentinel, malformed, and inverted setup ranges before they can enable
a buyer-visible rehearsal action or render as playable evidence.
- Localize Groove Map states and the unavailable Loop control through the
owned translation boundary, with literal fail-closed placeholder handling.
- 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
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 earliest analyzed note, or a validated playable range when no exact note exists. 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
45 changes: 30 additions & 15 deletions apps/desktop/src/features/workspace/GrooveMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import { memo, useMemo } from "react";
import type { TranscriptionNote } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";

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;
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);

// Find max offset to determine timeline width
const maxTime = useMemo(() => {
Expand All @@ -36,6 +40,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,21 +55,19 @@ 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%
{fillTranslation(t("grooveMapLoading"), { role: roleName })}
</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
{t("grooveMapCancel")}
</Button>
</div>
);
}

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">
{fillTranslation(t("grooveMapEmpty"), { role: roleName })}
</div>
);
}
Expand All @@ -68,13 +77,13 @@ 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={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })}
>
<div className="sr-only">
Transcription complete. {renderedNotes.length} notes analyzed.
{fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}
</div>
<p className="mb-3 text-xs font-black uppercase tracking-[0.22em] text-cyan-200">
{renderedNotes.length} notes mapped for rehearsal
{fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}
</p>

<div style={{ position: "relative", minWidth: "100%", height: `${uniquePitches.length * 40}px` }}>
Expand All @@ -95,20 +104,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 ? fillTranslation(t("grooveMapEntranceTitle"), { note: noteLabel }) : noteLabel}
>
<span className="sr-only">
{noteLabel}
{isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
</span>
</div>
);
Expand Down
180 changes: 180 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,180 @@
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 { afterEach, describe, expect, it, vi } 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", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

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("localizes GrooveMap states and the unavailable loop control", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = createDemoRehearsalSong();
const roleName = song.sections[0]!.roles[0]!.name;
const { rerender } = render(<GrooveMap roleName={roleName} notes={[]} />);

expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy();

rerender(<GrooveMap roleName={roleName} notes={[]} isLoading />);
expect(screen.getByText(`${roleName} 파트를 확인하는 중... 45%`)).toBeTruthy();
expect(screen.getByRole("button", { name: "취소" })).toBeTruthy();

rerender(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: roleName }));
expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복");
});

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("keeps disabled stem controls discoverable by their visible labels", () => {
const song = createDemoRehearsalSong();

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

fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem");
expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent(
"Solo / mute others"
);
});

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();
});

it.each([
["none", "none"],
["E3", "C#2"],
["low", "high"]
])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => {
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