Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 analysis, the ready workspace must name the first part to start. Do not leave Roles & Harmony or an empty collaboration card as a description that never opens tonight's chords, range, or simplification.
- 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
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
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.
- After analysis, the ready workspace must start the first extracted part so tonight's chords, range, and simplification are one action away. Empty collaboration copy must start that same part instead of waiting for assignments.

## Analysis target model

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

- Ready workspace now names the first extracted part and starts that role board so tonight's chords, range, and simplification are one action away.
- 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 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 analysis, Roles & Harmony and empty collaboration cards must name the first part and start that role board. Do not leave those ready-state surfaces as dead-end descriptions.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";

const originalLanguage = navigator.language;
const originalMatchMedia = window.matchMedia;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

function setNavigatorLanguage(language: string): void {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("Workspace pick-part reduced-motion navigation", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: originalMatchMedia
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView
});
vi.restoreAllMocks();
});

it("uses non-animated scrolling when reduced motion is requested", () => {
setNavigatorLanguage("en-US");
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn((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()
}))
});

render(<Workspace song={createDemoRehearsalSong()} />);
fireEvent.click(screen.getByRole("button", { name: "Start as Bass Guitar" }));

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "auto",
block: "nearest"
});
});
});
44 changes: 44 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,5 +325,49 @@ describe("Workspace", () => {
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
expect(screen.getByRole("button", { name: "Bass Guitar로 시작" })).toBeTruthy();
});

it("names the first part and opens that role board from the ready workspace", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

expect(screen.getByTestId("workspace-pick-part-callout")).toBeTruthy();
expect(screen.getByText(/Start as Bass Guitar to see tonight's chords/i)).toBeTruthy();
expect(screen.queryByText(/The bass holds the vi center/i)).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "Start as Bass Guitar" }));

expect(screen.queryByTestId("workspace-pick-part-callout")).toBeNull();
expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Bass Guitar", selected: true })).toBeTruthy();
});

it("starts the first part from an empty collaboration card", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.collaboration = undefined;

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

expect(screen.getByText(/No assignments yet. Start as Bass Guitar/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Start the room as Bass Guitar" }));

expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
});

it("hides the pick-part next action when the song has no roles", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
song.collaboration = undefined;

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

expect(screen.queryByTestId("workspace-pick-part-callout")).toBeNull();
expect(screen.queryByRole("button", { name: /Start as/i })).toBeNull();
expect(screen.getByText(/No assignments yet. Start as your part/i)).toBeTruthy();
});
});
87 changes: 82 additions & 5 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4 } from "lucide-react";
import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4, Users } from "lucide-react";

interface WorkspaceProps {
song: RehearsalSong;
Expand Down Expand Up @@ -71,6 +71,28 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro
}
}

/** Substitute the player-facing role name into a copy template. */
function fillRoleCopy(template: string, roleName: string): string {
return template.replaceAll("{role}", roleName);
}
Comment on lines +75 to +77

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: Role-name substitution honors $ replacement patterns

fillRoleCopy substitutes the role name via replaceAll("{role}", roleName), so a name containing $& or $1 would be reinterpreted rather than inserted verbatim. Role names come from validated analysis output, so this is harmless today, but it differs from fillRangeCopy (apps/desktop/src/features/workspace/firstRangeSqueeze.ts:166-172), which uses a function replacer immune to this.

Open in Devin Review

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


/** Scroll the roles board into view after the player starts a part. */
function focusWorkspaceRolesCard(): void {
const node = document.getElementById("workspace-roles-card");
if (!(node instanceof HTMLElement)) {
return;
}
const reduceMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (typeof node.scrollIntoView === "function") {
node.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" });
}
if (typeof node.focus === "function") {
node.focus();
}
Comment on lines +91 to +93

@devin-ai-integration devin-ai-integration Bot Aug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Focus jump cancels the smooth scroll to the part board

After starting a part, focusWorkspaceRolesCard runs scrollIntoView with behavior: "smooth", then immediately calls node.focus(). focus() scrolls the element into view instantly by default, so the smooth animation never plays. Passing focus({ preventScroll: true }) keeps it.

Open in Devin Review

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

}

/** Documented. */
const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) {
return (
Expand Down Expand Up @@ -151,6 +173,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
const firstRole = allRoles[0];
const firstRoleName = firstRole?.name.trim() || t("workspacePickPartFallback");

/** Start tonight's part view on the first extracted role. */
const handlePickFirstPart = () => {
if (!firstRole) {
return;
}
setActiveRole(firstRole.id);
focusWorkspaceRolesCard();
};
Comment on lines +176 to +186

@devin-ai-integration devin-ai-integration Bot Aug 25, 2026

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: No-roles fallback path is handled

With no roles, firstRole is undefined, so both the pick-part callout and the empty-collaboration button are hidden, and the copy falls back to workspacePickPartFallback ("your part"). This matches the added test and avoids a dead-end button.

Open in Devin Review

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


const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(
Expand Down Expand Up @@ -336,7 +370,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
</div>
</div>
) : (
<p className="mt-2 text-sm leading-6 text-slate-300">{t("workspaceCollaborationEmpty")}</p>
<div className="mt-2 space-y-3">
<p className="text-sm leading-6 text-slate-300">
{fillRoleCopy(t("workspaceCollaborationEmpty"), firstRoleName)}
</p>
{firstRole ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePickFirstPart}
className="min-h-10 border-emerald-300/30 bg-emerald-300/10 font-semibold text-emerald-50 hover:bg-emerald-300/20 hover:text-white"
aria-label={fillRoleCopy(t("workspaceCollaborationPickPart"), firstRoleName)}
>
<Users className="mr-2 size-4 text-emerald-200" aria-hidden="true" />
{fillRoleCopy(t("workspaceCollaborationPickPart"), firstRoleName)}
</Button>
) : null}
</div>
)}
</section>

Expand All @@ -355,11 +406,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

<SongStructure sections={song.sections} t={t} />

<section className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<section
id="workspace-roles-card"
tabIndex={-1}
className="rounded-2xl border border-white/10 bg-white/[0.04] p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-black uppercase tracking-[0.24em] text-slate-300">{t("workspaceRolesHarmonyLabel")}</p>
<p className="mt-1 text-sm text-slate-400">Filter the board by player or vocal role without losing the full form context.</p>
<p className="mt-1 text-sm text-slate-400">{t("workspaceRolesHarmonyHint")}</p>
</div>
<RoleSwitcher
roles={allRoles}
Expand All @@ -368,6 +423,28 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
/>
</div>

{!activeRole && firstRole ? (
<div
data-testid="workspace-pick-part-callout"
className="mb-4 rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4"
>
<p className="text-sm leading-6 text-slate-200">
{fillRoleCopy(t("workspacePickPartHint"), firstRoleName)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePickFirstPart}
className="mt-3 min-h-10 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white"
aria-label={fillRoleCopy(t("workspacePickPartAction"), firstRoleName)}
>
<Users className="mr-2 size-4 text-cyan-200" aria-hidden="true" />
{fillRoleCopy(t("workspacePickPartAction"), firstRoleName)}
</Button>
</div>
) : null}

{activeRole && (
<div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/[0.06] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200">Stem Player</p>
Expand Down Expand Up @@ -512,4 +589,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
</Card>
</div>
);
}
}
7 changes: 6 additions & 1 deletion apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"workspaceRehearsalTimelineLabel": "Rehearsal timeline",
"workspaceSongTimelineLabel": "Song Timeline",
"workspaceCollaborationLabel": "Collaboration",
"workspaceCollaborationEmpty": "Assignments, comments, and approvals will show up here as the room aligns.",
"workspaceCollaborationEmpty": "No assignments yet. Start as {role} so the room can see who locks this part tonight.",
"workspaceCollaborationPickPart": "Start the room as {role}",
"workspaceSyncStatusLabel": "Sync",
"workspaceAssignmentsLabel": "Assignments",
"workspaceCommentsLabel": "Comments",
Expand All @@ -50,6 +51,10 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
"workspaceRolesHarmonyHint": "Pick your part to open tonight's chords, range, and simplification.",
"workspacePickPartHint": "Start as {role} to see tonight's chords, range, and what to simplify.",
"workspacePickPartAction": "Start as {role}",
"workspacePickPartFallback": "your part",
"sectionRoadmapTitle": "Section Roadmap",
"sectionRoadmapScrollHint": "Scroll for more sections →",
"sectionGrooveLabel": "Groove",
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"workspaceRehearsalTimelineLabel": "합주 타임라인",
"workspaceSongTimelineLabel": "곡 타임라인",
"workspaceCollaborationLabel": "협업",
"workspaceCollaborationEmpty": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.",
"workspaceCollaborationEmpty": "아직 담당이 없습니다. {role}로 시작하면 오늘 이 파트를 누가 잠글지 합주실에 보입니다.",
"workspaceCollaborationPickPart": "합주실을 {role}로 시작",
"workspaceSyncStatusLabel": "동기화",
"workspaceAssignmentsLabel": "담당",
"workspaceCommentsLabel": "코멘트",
Expand All @@ -50,6 +51,10 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
"workspaceRolesHarmonyHint": "파트를 고르면 오늘 칠 화성, 음역, 단순화 힌트가 열립니다.",
"workspacePickPartHint": "오늘 코드, 음역, 단순화할 자리를 보려면 {role}로 시작하세요.",
"workspacePickPartAction": "{role}로 시작",
"workspacePickPartFallback": "내 파트",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
Expand Down
2 changes: 1 addition & 1 deletion docs/design-system/component-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
| Metric Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-216 | `apps/desktop/src/App.tsx` | Feature-local `MetricCard({ icon, label, value, detail, accent? })`. Metrics follow source controls on mobile. |
| Confidence Badge | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-239 | `apps/desktop/src/features/workspace/ConfidenceBadge.tsx` | Use `level: ConfidenceLevel`; no `score` or `label` prop exists in current code. |
| Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. |
| Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. |
| Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. When no role is selected, Workspace must name the first part and start that board. |
| Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. |
| Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. |
| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. |
Expand Down
Loading