From 5e55876b0849349d5399315b4a652daf8cb4f5c6 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Tue, 18 Aug 2026 00:18:08 +0000
Subject: [PATCH 01/25] feat(workspace): arm tonight's first setup on the role
strip
Replace the inert Transcribe Bass control with a named setup action
from setupNote, transposition, or simplification, then point at the
first analyzed entrance on the groove map. Isolation stays fail-closed.
---
AGENTS.md | 1 +
ARCHITECTURE.md | 1 +
CHANGELOG.md | 1 +
CLAUDE.md | 2 +
.../src/features/workspace/GrooveMap.tsx | 21 +-
.../src/features/workspace/Workspace.test.tsx | 84 +++++++-
.../src/features/workspace/Workspace.tsx | 180 ++++++++++++++++--
apps/desktop/src/locales/en/common.json | 13 ++
apps/desktop/src/locales/ko/common.json | 13 ++
docs/design-system/component-contract.md | 2 +-
10 files changed, 289 insertions(+), 29 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index fca448ce9..bc01dd5d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3302a6fc3..1c2ac5ccd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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.
## Analysis target model
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eea696893..58358fa97 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index 82c2c704a..b62b29aa2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index 2745d4d79..514719b7f 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -9,10 +9,11 @@ const EMPTY_NOTES: TranscriptionNote[] = [];
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
+ entranceOnset?: number;
}
/** Documented. */
-function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
+function GrooveMapComponent({ notes, isLoading, entranceOnset }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;
// Find max offset to determine timeline width
@@ -55,9 +56,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
if (renderedNotes.length === 0) {
return (
-
+
No bass line transcription yet. Use it when you want to check the groove before rehearsal.
);
@@ -65,7 +64,7 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
return (
- {noteLabel}
+ {isEntrance ? `Tonight's entrance. ${noteLabel}` : noteLabel}
);
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index a3da5ffe6..690b3b400 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export";
const originalLanguage = navigator.language;
const originalCreateObjectUrl = URL.createObjectURL;
const originalRevokeObjectUrl = URL.revokeObjectURL;
+const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
@@ -28,6 +29,7 @@ describe("Workspace", () => {
configurable: true,
value: originalRevokeObjectUrl
});
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it("updates practice progress immutably through onSongUpdate", () => {
@@ -85,20 +87,23 @@ describe("Workspace", () => {
expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
- it("enables bass transcription from selected role metadata rather than role id text", () => {
+ it("enables tonight's setup from the role setup cue rather than the role name", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "low-end",
- name: "Bass Guitar"
+ name: "Bass Guitar",
+ setupNote: "Keep the attack short so the verse breathes."
};
render(
);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement;
- expect(transcribeButton.disabled).toBe(false);
- expect(transcribeButton.title).toBe("Transcribe part");
+ const setupButton = screen.getByRole("button", {
+ name: /Set up Bass Guitar: Keep the attack short so the verse breathes/i
+ }) as HTMLButtonElement;
+ expect(setupButton.disabled).toBe(false);
+ expect(screen.queryByRole("button", { name: "Transcribe Bass" })).toBeNull();
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
@@ -270,4 +275,73 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
+
+ it("arms tonight's setup and names the first later-section entrance", () => {
+ const song = createDemoRehearsalSong();
+ const verseRole = song.sections[0]!.roles[0]!;
+ song.sections[0]!.roles[0] = {
+ ...verseRole,
+ transcription: undefined
+ };
+ song.sections.push({
+ ...song.sections[0]!,
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: 40, end: 64 },
+ roles: [
+ {
+ ...verseRole,
+ transcription: [{ pitch: "A2", onset: 42, offset: 42.75, velocity: 0.7 }]
+ }
+ ]
+ });
+ const scrollIntoView = vi.fn();
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
+
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: /Then start on A2 from 0:42 on tonight's groove map/i
+ })
+ );
+
+ expect(
+ screen.getByText(
+ "Tonight's Bass Guitar setup: Keep the attack short so the verse breathes. Start on A2 from 0:42 on the groove map."
+ )
+ ).toBeTruthy();
+ expect(document.activeElement?.id).toBe("workspace-role-setup");
+ expect(document.getElementById("workspace-groove-entrance")).toBeTruthy();
+ expect(scrollIntoView).toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "Isolation is not ready. Set up tonight's part first." })).toBeTruthy();
+ });
+
+ it("keeps setup unavailable when the role has no setup cue", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ setupNote: " ",
+ transpositionPlan: "",
+ simplification: " "
+ };
+
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ const setupButton = screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." });
+ expect(setupButton.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(setupButton);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("localizes tonight's setup action without broken Korean particles", () => {
+ setNavigatorLanguage("ko-KR");
+ const song = createDemoRehearsalSong();
+
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByRole("button", { name: /Bass Guitar 세팅: Keep the attack short so the verse breathes/ })).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 71546b524..2ad18a704 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -40,6 +40,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi
}
type Translator = ReturnType
;
+type TranscriptionNote = NonNullable[number];
/** Documented. */
function preventUnavailableAction(event: MouseEvent): void {
@@ -57,6 +58,50 @@ function nonBlankText(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
+/** Fill rehearsal copy with named placeholders. */
+function fillCopy(template: string, values: Record): string {
+ return Object.entries(values).reduce(
+ (text, [key, value]) => text.replaceAll(`{${key}}`, value),
+ template
+ );
+}
+
+/** Return the earliest analyzed note so setup can name the first attack. */
+function firstTranscriptionNote(notes: RehearsalRole["transcription"]): TranscriptionNote | undefined {
+ if (!notes || notes.length === 0) {
+ return undefined;
+ }
+
+ let earliest = notes[0]!;
+ for (const note of notes) {
+ if (note.onset < earliest.onset) {
+ earliest = note;
+ }
+ }
+ return earliest;
+}
+
+/** Prefer the role's setup cue, then transpose, then simplification. */
+function roleSetupCue(role: RehearsalRole | undefined): string | undefined {
+ return nonBlankText(role?.setupNote) ?? nonBlankText(role?.transpositionPlan) ?? nonBlankText(role?.simplification);
+}
+
+/** Scroll and focus the setup card the player should follow next. */
+function focusRoleSetup(): void {
+ const node = document.getElementById("workspace-role-setup");
+ if (!(node instanceof HTMLElement)) {
+ return;
+ }
+ const prefersReducedMotion =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ node.scrollIntoView({
+ behavior: prefersReducedMotion ? "auto" : "smooth",
+ block: "nearest"
+ });
+ node.focus();
+}
+
/** Documented. */
function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null {
if (!value) {
@@ -120,6 +165,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState(null);
+ const [armedSetupRoleId, setArmedSetupRoleId] = useState(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
// Extract all unique roles from the song's sections
@@ -149,7 +195,26 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
if (!activeRole) return undefined;
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
- const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
+ const activeRoleTranscription = useMemo(() => {
+ if (!activeRole) return undefined;
+ const notes: TranscriptionNote[] = [];
+ for (const section of song.sections) {
+ for (const role of section.roles) {
+ if (role.id !== activeRole || !role.transcription) continue;
+ for (const note of role.transcription) {
+ notes.push(note);
+ }
+ }
+ }
+ if (notes.length === 0) return undefined;
+ notes.sort((left, right) => left.onset - right.onset);
+ return notes;
+ }, [activeRole, song.sections]);
+ const firstNote = firstTranscriptionNote(activeRoleTranscription);
+ const roleRangeLow = nonBlankText(activeRoleDetails?.range.lowestNote);
+ const roleRangeHigh = nonBlankText(activeRoleDetails?.range.highestNote);
+ const setupCue = roleSetupCue(activeRoleDetails);
+ const canArmTonightSetup = Boolean(setupCue);
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -212,6 +277,71 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
+ const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
+ const setupActionLabel = !setupCue
+ ? t("workspaceSetupUnavailable")
+ : firstNote
+ ? fillCopy(t("workspaceSetupActionWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset)
+ })
+ : roleRangeLow && roleRangeHigh
+ ? fillCopy(t("workspaceSetupActionWithRange"), {
+ role: roleName,
+ low: roleRangeLow,
+ high: roleRangeHigh
+ })
+ : fillCopy(t("workspaceSetupAction"), { role: roleName });
+ const setupAriaLabel = !setupCue
+ ? t("workspaceSetupUnavailable")
+ : firstNote
+ ? fillCopy(t("workspaceSetupAriaWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupCue
+ })
+ : roleRangeLow && roleRangeHigh
+ ? fillCopy(t("workspaceSetupAriaWithRange"), {
+ role: roleName,
+ low: roleRangeLow,
+ high: roleRangeHigh,
+ setup: setupCue
+ })
+ : fillCopy(t("workspaceSetupAria"), { role: roleName, setup: setupCue });
+ const setupStatus = !setupCue
+ ? t("workspaceSetupUnavailable")
+ : firstNote
+ ? fillCopy(t("workspaceSetupArmedWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupCue
+ })
+ : roleRangeLow && roleRangeHigh
+ ? fillCopy(t("workspaceSetupArmedWithRange"), {
+ role: roleName,
+ low: roleRangeLow,
+ high: roleRangeHigh,
+ setup: setupCue
+ })
+ : fillCopy(t("workspaceSetupArmed"), { role: roleName, setup: setupCue });
+
+ /** Arm tonight's setup and move focus to the setup card. */
+ const armTonightSetup = (): void => {
+ if (!activeRole || !canArmTonightSetup) {
+ return;
+ }
+ setArmedSetupRoleId(activeRole);
+ focusRoleSetup();
+ };
+
+ /** Keep the role board and armed setup on the same selected part. */
+ const handleRoleChange = (roleId: string | null): void => {
+ setActiveRole(roleId);
+ setArmedSetupRoleId(null);
+ };
/** Documented. */
const handleExportCueSheet = () => {
@@ -342,7 +472,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -354,8 +484,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
Solo / mute others
- {canTranscribeBass ? (
+ {canArmTonightSetup ? (
- Transcribe Bass
+ {setupActionLabel}
) : (
- Transcribe Bass
+ {t("workspaceSetupUnavailable")}
)}
@@ -416,13 +549,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
-
+
{t("workspaceTranspositionLabel")}
- {roleTranspositionPlan}
+ {setupCue ?? roleTranspositionPlan}
@@ -476,7 +617,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
-
+ {armedSetupRoleId === activeRole ? (
+
+ {setupStatus}
+
+ ) : null}
+
)}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 39f716d50..296301177 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -50,6 +50,19 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
+ "workspaceThisRole": "This part",
+ "workspaceSetupAction": "Set up {role}",
+ "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
+ "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
+ "workspaceSetupAria": "Set up {role}: {setup}",
+ "workspaceSetupAriaWithNote": "Set up {role}: {setup}. Then start on {pitch} from {start} on tonight's groove map",
+ "workspaceSetupAriaWithRange": "Set up {role}: {setup}. Then start in {low}–{high} on tonight's map",
+ "workspaceSetupArmed": "Tonight's {role} setup: {setup}. Start from that card.",
+ "workspaceSetupArmedWithNote": "Tonight's {role} setup: {setup}. Start on {pitch} from {start} on the groove map.",
+ "workspaceSetupArmedWithRange": "Tonight's {role} setup: {setup}. Start in {low}–{high} on the map.",
+ "workspaceSetupUnavailable": "No setup cue yet. Stay on tonight's map.",
+ "workspacePlayStemUnavailable": "Isolation is not ready. Set up tonight's part first.",
+ "workspaceSoloUnavailable": "Solo stays off until isolation is honest. Set up tonight's part first.",
"sectionRoadmapTitle": "Section Roadmap",
"sectionRoadmapScrollHint": "Scroll for more sections →",
"sectionGrooveLabel": "Groove",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 371884abb..7ff8c7e6c 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -50,6 +50,19 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
+ "workspaceThisRole": "이 파트",
+ "workspaceSetupAction": "{role} 세팅",
+ "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
+ "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
+ "workspaceSetupAria": "{role} 세팅: {setup}",
+ "workspaceSetupAriaWithNote": "{role} 세팅: {setup}. 오늘 그루브 맵에서 {start}의 {pitch}부터 시작",
+ "workspaceSetupAriaWithRange": "{role} 세팅: {setup}. 오늘 지도에서 {low}–{high}로 시작",
+ "workspaceSetupArmed": "오늘 {role} 세팅: {setup}. 그 카드에서 시작하세요.",
+ "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
+ "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
+ "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
+ "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
+ "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..1854a03df 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -31,7 +31,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
| 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. |
| 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. |
+| 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[]`, `isLoading?: boolean`, and optional `entranceOnset` to mark tonight's first attack; preserve scrollable region semantics and note labels. |
| Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. |
| Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. |
| Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. |
From 25bd74f1acec9c3b8c32c465c5b9d0dabbae75fc Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:06:21 +0900
Subject: [PATCH 02/25] fix(workspace): preserve setup copy and transposition
detail
---
.../src/features/workspace/Workspace.tsx | 21 +++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 2ad18a704..55a5c2a63 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -58,6 +58,11 @@ function nonBlankText(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
+/** Remove terminal sentence punctuation before embedding a cue in a larger sentence. */
+function sentenceFragment(value: string): string {
+ return value.replace(/[.!?。!?]+$/u, "").trimEnd();
+}
+
/** Fill rehearsal copy with named placeholders. */
function fillCopy(template: string, values: Record): string {
return Object.entries(values).reduce(
@@ -214,6 +219,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleRangeLow = nonBlankText(activeRoleDetails?.range.lowestNote);
const roleRangeHigh = nonBlankText(activeRoleDetails?.range.highestNote);
const setupCue = roleSetupCue(activeRoleDetails);
+ const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
const canArmTonightSetup = Boolean(setupCue);
/** Handle the practice progress change internally by immutably updating the song state. */
@@ -300,16 +306,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
role: roleName,
pitch: firstNote.pitch,
start: formatTimelineTime(firstNote.onset),
- setup: setupCue
+ setup: setupSentenceCue
})
: roleRangeLow && roleRangeHigh
? fillCopy(t("workspaceSetupAriaWithRange"), {
role: roleName,
low: roleRangeLow,
high: roleRangeHigh,
- setup: setupCue
+ setup: setupSentenceCue
})
- : fillCopy(t("workspaceSetupAria"), { role: roleName, setup: setupCue });
+ : fillCopy(t("workspaceSetupAria"), { role: roleName, setup: setupSentenceCue });
const setupStatus = !setupCue
? t("workspaceSetupUnavailable")
: firstNote
@@ -317,16 +323,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
role: roleName,
pitch: firstNote.pitch,
start: formatTimelineTime(firstNote.onset),
- setup: setupCue
+ setup: setupSentenceCue
})
: roleRangeLow && roleRangeHigh
? fillCopy(t("workspaceSetupArmedWithRange"), {
role: roleName,
low: roleRangeLow,
high: roleRangeHigh,
- setup: setupCue
+ setup: setupSentenceCue
})
- : fillCopy(t("workspaceSetupArmed"), { role: roleName, setup: setupCue });
+ : fillCopy(t("workspaceSetupArmed"), { role: roleName, setup: setupSentenceCue });
/** Arm tonight's setup and move focus to the setup card. */
const armTonightSetup = (): void => {
@@ -565,6 +571,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{setupCue ?? roleTranspositionPlan}
+ {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? (
+ {roleTranspositionPlan}
+ ) : null}
{song.collaboration && (
From 1db3ff027c4beed7304cbf491d7a320e7f65090e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:16:31 +0900
Subject: [PATCH 03/25] test(workspace): lock review regressions
---
.../workspace/Workspace.review.test.tsx | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 apps/desktop/src/features/workspace/Workspace.review.test.tsx
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
new file mode 100644
index 000000000..7dd8ac0dc
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -0,0 +1,85 @@
+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, 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("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
+ render(
+
+ );
+
+ 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( );
+ expect(screen.getByText("No Lead Guitar transcription yet. Use it when you want to check the groove before rehearsal.")).toBeTruthy();
+
+ rerender( );
+ 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( );
+ 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("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( );
+ 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();
+ });
+});
From 12b1c4a59992eca1f883b31bf71ae4da2727542f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:17:40 +0900
Subject: [PATCH 04/25] fix(workspace): make groove map role-aware and
accessible
---
.../src/features/workspace/GrooveMap.tsx | 26 ++++++++++++-------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index 514719b7f..da5f3b752 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -5,15 +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, entranceOnset }: 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
@@ -37,6 +38,13 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset }: GrooveMapProps)
return map;
}, [uniquePitches]);
+ const entranceIndex = useMemo(() => {
+ if (entranceOnset === undefined) {
+ return -1;
+ }
+ return renderedNotes.findIndex((note) => note.onset === entranceOnset);
+ }, [entranceOnset, renderedNotes]);
+
if (isLoading) {
return (
- Checking the bass line... 45%
+ Checking the {roleName} line... 45%
Cancel
@@ -57,17 +65,17 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset }: GrooveMapProps)
if (renderedNotes.length === 0) {
return (
- No bass line transcription yet. Use it when you want to check the groove before rehearsal.
+ No {roleName} transcription yet. Use it when you want to check the groove before rehearsal.
);
}
return (
Transcription complete. {renderedNotes.length} notes analyzed.
@@ -99,7 +107,7 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset }: GrooveMapProps)
return (
Date: Tue, 18 Aug 2026 10:19:24 +0900
Subject: [PATCH 05/25] fix(workspace): require actionable start evidence
---
.../src/features/workspace/Workspace.tsx | 77 +++++++++----------
1 file changed, 37 insertions(+), 40 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 55a5c2a63..192bb520e 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -220,7 +220,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleRangeHigh = nonBlankText(activeRoleDetails?.range.highestNote);
const setupCue = roleSetupCue(activeRoleDetails);
const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
- const canArmTonightSetup = Boolean(setupCue);
+ const hasPlayableRange = Boolean(roleRangeLow && roleRangeHigh);
+ const hasStartEvidence = Boolean(firstNote || hasPlayableRange);
+ const canArmTonightSetup = Boolean(setupCue && hasStartEvidence);
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -284,23 +286,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
- const setupActionLabel = !setupCue
- ? t("workspaceSetupUnavailable")
+ const setupUnavailableLabel = setupCue && !hasStartEvidence
+ ? t("workspaceSetupStartUnavailable")
+ : t("workspaceSetupUnavailable");
+ const setupActionLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
: firstNote
? fillCopy(t("workspaceSetupActionWithNote"), {
role: roleName,
pitch: firstNote.pitch,
start: formatTimelineTime(firstNote.onset)
})
- : roleRangeLow && roleRangeHigh
- ? fillCopy(t("workspaceSetupActionWithRange"), {
- role: roleName,
- low: roleRangeLow,
- high: roleRangeHigh
- })
- : fillCopy(t("workspaceSetupAction"), { role: roleName });
- const setupAriaLabel = !setupCue
- ? t("workspaceSetupUnavailable")
+ : fillCopy(t("workspaceSetupActionWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!
+ });
+ const setupAriaLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
: firstNote
? fillCopy(t("workspaceSetupAriaWithNote"), {
role: roleName,
@@ -308,31 +311,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
start: formatTimelineTime(firstNote.onset),
setup: setupSentenceCue
})
- : roleRangeLow && roleRangeHigh
- ? fillCopy(t("workspaceSetupAriaWithRange"), {
- role: roleName,
- low: roleRangeLow,
- high: roleRangeHigh,
- setup: setupSentenceCue
- })
- : fillCopy(t("workspaceSetupAria"), { role: roleName, setup: setupSentenceCue });
- const setupStatus = !setupCue
- ? t("workspaceSetupUnavailable")
- : firstNote
- ? fillCopy(t("workspaceSetupArmedWithNote"), {
+ : fillCopy(t("workspaceSetupAriaWithRange"), {
role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset),
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
setup: setupSentenceCue
- })
- : roleRangeLow && roleRangeHigh
- ? fillCopy(t("workspaceSetupArmedWithRange"), {
- role: roleName,
- low: roleRangeLow,
- high: roleRangeHigh,
- setup: setupSentenceCue
- })
- : fillCopy(t("workspaceSetupArmed"), { role: roleName, setup: setupSentenceCue });
+ });
+ const setupStatus = firstNote
+ ? fillCopy(t("workspaceSetupArmedWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupSentenceCue
+ })
+ : fillCopy(t("workspaceSetupArmedWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
+ setup: setupSentenceCue
+ });
/** Arm tonight's setup and move focus to the setup card. */
const armTonightSetup = (): void => {
@@ -534,14 +531,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
) : (
- {t("workspaceSetupUnavailable")}
+ {setupUnavailableLabel}
)}
@@ -635,6 +631,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
notes={activeRoleTranscription ?? activeRoleDetails?.transcription}
isLoading={false}
entranceOnset={armedSetupRoleId === activeRole ? firstNote?.onset : undefined}
+ roleName={roleName}
/>
)}
From 1c841b0fa58abcfa9d226197b424d128f6f0b04d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:19:53 +0900
Subject: [PATCH 06/25] fix(workspace): explain missing start evidence
---
apps/desktop/src/locales/en/common.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 296301177..f33ba8344 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -61,6 +61,7 @@
"workspaceSetupArmedWithNote": "Tonight's {role} setup: {setup}. Start on {pitch} from {start} on the groove map.",
"workspaceSetupArmedWithRange": "Tonight's {role} setup: {setup}. Start in {low}–{high} on the map.",
"workspaceSetupUnavailable": "No setup cue yet. Stay on tonight's map.",
+ "workspaceSetupStartUnavailable": "No first entrance or playable range yet. Stay on tonight's map.",
"workspacePlayStemUnavailable": "Isolation is not ready. Set up tonight's part first.",
"workspaceSoloUnavailable": "Solo stays off until isolation is honest. Set up tonight's part first.",
"sectionRoadmapTitle": "Section Roadmap",
@@ -162,4 +163,4 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress"
-}
+}
\ No newline at end of file
From 6f686fe4ff9811f048d8fe26b0575a2a1a5c2d0d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:20:26 +0900
Subject: [PATCH 07/25] fix(workspace): localize missing start evidence
---
apps/desktop/src/locales/ko/common.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 7ff8c7e6c..8bbdde950 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -61,6 +61,7 @@
"workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
"workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
"workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
+ "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.",
"workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
"workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
"sectionRoadmapTitle": "구간 흐름",
@@ -162,4 +163,4 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가"
-}
+}
\ No newline at end of file
From 6d838ca067a805c2c2fdce788aee16c000259c67 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:21:18 +0900
Subject: [PATCH 08/25] test(workspace): assert native setup disable
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 690b3b400..3acb08af9 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -330,7 +330,7 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
const setupButton = screen.getByRole("button", { name: "No setup cue yet. Stay on tonight's map." });
- expect(setupButton.getAttribute("aria-disabled")).toBe("true");
+ expect(setupButton).toBeDisabled();
fireEvent.click(setupButton);
expect(screen.queryByRole("status")).toBeNull();
});
@@ -344,4 +344,4 @@ describe("Workspace", () => {
expect(screen.getByRole("button", { name: /Bass Guitar 세팅: Keep the attack short so the verse breathes/ })).toBeTruthy();
});
-});
+});
\ No newline at end of file
From 098f6347169f063bf48f10ac0f029c72fe301af0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:21:54 +0900
Subject: [PATCH 09/25] docs(architecture): refresh current guidance date
---
ARCHITECTURE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1c2ac5ccd..b6eea5dff 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-03-11
+Last updated: 2026-08-18
## Brand source
From d27bff628d6d7ba7af0cfc7218d9ec82d6539e7d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:22:37 +0900
Subject: [PATCH 10/25] docs(design): keep groove-map contract code-current
---
docs/design-system/component-contract.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 1854a03df..a3f798e23 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -31,7 +31,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
| 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. |
| 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[]`, `isLoading?: boolean`, and optional `entranceOnset` to mark tonight's first attack; preserve scrollable region semantics and note labels. |
+| Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, and optional `entranceOnset` to mark tonight's first attack; preserve scrollable region semantics, focus-visible styling, and note labels. |
| Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. |
| Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. |
| Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. |
From fd3665130c8f6eafcec5a0e14b427221b5594dd0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:23:08 +0900
Subject: [PATCH 11/25] docs(changelog): record workspace review repairs
---
CHANGELOG.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58358fa97..e98175b85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
- 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
@@ -66,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`).
\ No newline at end of file
From 184ad06df242fe2c61dd88554558e82bba73ce1a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 10:39:13 +0900
Subject: [PATCH 12/25] test(workspace): expect role-specific groove map label
---
apps/desktop/src/features/workspace/Workspace.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 3acb08af9..d2a944bab 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -120,7 +120,7 @@ describe("Workspace", () => {
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i });
+ const grooveMap = screen.getByRole("region", { name: /bass guitar transcription groove map/i });
expect(grooveMap.className).toContain("bg-slate-950");
expect(screen.getByText("E2")).toBeTruthy();
expect(screen.getByText("G2")).toBeTruthy();
From b06be3f3d8e138aee1032d657303fa36c1a84324 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 01:16:26 -0700
Subject: [PATCH 13/25] test(workspace): keep setup placeholders literal
---
.../workspace/Workspace.review.test.tsx | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
index 7dd8ac0dc..2f483c6a9 100644
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -60,6 +60,27 @@ describe("Workspace review regressions", () => {
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( );
+ fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
+
+ expect(screen.getByRole("button", { name: "Set up {low} · then start in C#2–E3" })).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;
From 0ad6315107c6f4aafb2e9daea9f33e74f06377b2 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 01:24:54 -0700
Subject: [PATCH 14/25] fix(workspace): interpolate setup copy once
---
apps/desktop/src/features/workspace/Workspace.tsx | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 192bb520e..7e45e51ce 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -63,12 +63,10 @@ function sentenceFragment(value: string): string {
return value.replace(/[.!?。!?]+$/u, "").trimEnd();
}
-/** Fill rehearsal copy with named placeholders. */
+/** Interpolate the trusted placeholder keys in one pass so rehearsal data is never reinterpreted as template syntax. */
function fillCopy(template: string, values: Record): string {
- return Object.entries(values).reduce(
- (text, [key, value]) => text.replaceAll(`{${key}}`, value),
- template
- );
+ const keyPattern = Object.keys(values).join("|");
+ return template.replace(new RegExp(`\\{(${keyPattern})\\}`, "g"), (_placeholder, key: string) => values[key]!);
}
/** Return the earliest analyzed note so setup can name the first attack. */
From 16abeb10842e74320b03bae8f3515dc7c4b4f16e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 03:12:40 -0700
Subject: [PATCH 15/25] test(security): forbid dynamic regex copy interpolation
---
.../src/features/workspace/Workspace.review.test.tsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
index 2f483c6a9..dd613fffa 100644
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { describe, expect, it } from "vitest";
@@ -13,6 +14,11 @@ function replaceRole(song: ReturnType, roleId: s
}
describe("Workspace review regressions", () => {
+ it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
+ const source = readFileSync(new URL("./Workspace.tsx", import.meta.url), "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(
Date: Tue, 18 Aug 2026 03:15:24 -0700
Subject: [PATCH 16/25] fix(security): use fixed copy interpolation pattern
---
apps/desktop/src/features/workspace/Workspace.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index 7e45e51ce..07ac64004 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -63,10 +63,11 @@ function sentenceFragment(value: string): string {
return value.replace(/[.!?。!?]+$/u, "").trimEnd();
}
-/** Interpolate the trusted placeholder keys in one pass so rehearsal data is never reinterpreted as template syntax. */
+const COPY_PLACEHOLDER_PATTERN = /\{(role|pitch|start|setup|low|high)\}/g;
+
+/** Interpolate the fixed rehearsal-copy placeholder vocabulary without constructing a runtime regular expression. */
function fillCopy(template: string, values: Record): string {
- const keyPattern = Object.keys(values).join("|");
- return template.replace(new RegExp(`\\{(${keyPattern})\\}`, "g"), (_placeholder, key: string) => values[key]!);
+ return template.replace(COPY_PLACEHOLDER_PATTERN, (_placeholder, key: string) => values[key]!);
}
/** Return the earliest analyzed note so setup can name the first attack. */
From 61aa51f274c04d60289da7919a70d40155f2674d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 04:03:38 -0700
Subject: [PATCH 17/25] test(workspace): make setup regressions portable
---
.../src/features/workspace/Workspace.review.test.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
index dd613fffa..81d4dcd0a 100644
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -1,4 +1,5 @@
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";
@@ -15,7 +16,7 @@ function replaceRole(song: ReturnType, roleId: s
describe("Workspace review regressions", () => {
it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
- const source = readFileSync(new URL("./Workspace.tsx", import.meta.url), "utf8");
+ const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
expect(source).not.toContain("new RegExp(");
});
@@ -84,7 +85,11 @@ describe("Workspace review regressions", () => {
render( );
fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
- expect(screen.getByRole("button", { name: "Set up {low} · then start in C#2–E3" })).toBeEnabled();
+ 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", () => {
@@ -109,4 +114,4 @@ describe("Workspace review regressions", () => {
});
expect(setupButton).toBeDisabled();
});
-});
+});
\ No newline at end of file
From d14634bcfd7164401227c012ddbd60ac4cda73ad Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 21:13:36 -0700
Subject: [PATCH 18/25] docs(changelog): remove duplicated test entry
---
CHANGELOG.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed5606827..4e091d1fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,5 +73,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 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
From a6bc38de9ccc3af0134116869416099a829ee450 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 21:15:13 -0700
Subject: [PATCH 19/25] test(workspace): require visible setup label in
accessible name
---
apps/desktop/src/features/workspace/Workspace.review.test.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
index 81d4dcd0a..e3f92228b 100644
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -65,6 +65,9 @@ describe("Workspace review regressions", () => {
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", () => {
From 48508523aa7041e0cad4f76ae4b890be581e2f83 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 00:07:23 -0700
Subject: [PATCH 20/25] fix(a11y): include visible setup action in accessible
name
---
apps/desktop/src/locales/en/common.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index f33ba8344..f5ee78b19 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -55,8 +55,8 @@
"workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
"workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
"workspaceSetupAria": "Set up {role}: {setup}",
- "workspaceSetupAriaWithNote": "Set up {role}: {setup}. Then start on {pitch} from {start} on tonight's groove map",
- "workspaceSetupAriaWithRange": "Set up {role}: {setup}. Then start in {low}–{high} on tonight's map",
+ "workspaceSetupAriaWithNote": "Set up {role} · then start on {pitch} from {start}. Setup: {setup}. Use tonight's groove map",
+ "workspaceSetupAriaWithRange": "Set up {role} · then start in {low}–{high}. Setup: {setup}. Use tonight's map",
"workspaceSetupArmed": "Tonight's {role} setup: {setup}. Start from that card.",
"workspaceSetupArmedWithNote": "Tonight's {role} setup: {setup}. Start on {pitch} from {start} on the groove map.",
"workspaceSetupArmedWithRange": "Tonight's {role} setup: {setup}. Start in {low}–{high} on the map.",
From 8d1fdd2992f59f4700ba28d693ccabbd0938ae43 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 00:09:31 -0700
Subject: [PATCH 21/25] fix(a11y): align Korean setup accessible names
---
apps/desktop/src/locales/ko/common.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 8bbdde950..e95a6d9f8 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -55,8 +55,8 @@
"workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
"workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
"workspaceSetupAria": "{role} 세팅: {setup}",
- "workspaceSetupAriaWithNote": "{role} 세팅: {setup}. 오늘 그루브 맵에서 {start}의 {pitch}부터 시작",
- "workspaceSetupAriaWithRange": "{role} 세팅: {setup}. 오늘 지도에서 {low}–{high}로 시작",
+ "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요",
+ "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요",
"workspaceSetupArmed": "오늘 {role} 세팅: {setup}. 그 카드에서 시작하세요.",
"workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
"workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
From 1cc2699cd7d1423939dbc05977ceb4ca639c3bd1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 00:10:00 -0700
Subject: [PATCH 22/25] test(a11y): align setup accessible-name oracle
---
apps/desktop/src/features/workspace/Workspace.review.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
index e3f92228b..35e331730 100644
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -90,7 +90,7 @@ describe("Workspace review regressions", () => {
expect(
screen.getByRole("button", {
- name: "Set up {low}: Tune down a whole step. Then start in C#2–E3 on tonight's map"
+ name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map"
})
).toBeEnabled();
});
From 4e252a421bcdd49b9443ae5cbbe9eb3a9c90f51a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 00:10:39 -0700
Subject: [PATCH 23/25] test(changelog): preserve shipped security fix
classification
---
.../analysis-engine/tests/test_release_metadata.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py
index ec6b39a32..8e6408ee2 100644
--- a/services/analysis-engine/tests/test_release_metadata.py
+++ b/services/analysis-engine/tests/test_release_metadata.py
@@ -62,6 +62,16 @@ def test_changelog_contains_root_package_release_entry() -> None:
assert f"## [{root_package_version()}]" in changelog
+def test_changelog_preserves_dependency_security_baseline_as_fixed() -> None:
+ """Keep the shipped dependency-security repair classified as a fix in Unreleased."""
+ changelog = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8")
+ unreleased = changelog.split("## [0.1.3]", maxsplit=1)[0]
+ fixed = unreleased.split("### Fixed", maxsplit=1)[1]
+ security_fix = "Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108"
+
+ assert security_fix in fixed
+
+
def test_changelog_level_three_headings_are_surrounded_by_blank_lines() -> None:
"""Ensure changelog subsections stay compatible with Markdown heading lint."""
lines = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8").splitlines()
From cce6ff4f5768210f2c619956242b8257ccd0174a Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 00:11:09 -0700
Subject: [PATCH 24/25] fix(changelog): preserve protected security history
---
CHANGELOG.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e091d1fd..524e1b1e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,12 @@
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
### Changed
+
- Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata.
-- 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.
+
### 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
From b6bcecb8649796dc13a54c39d70ca05977b0ac4c Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Fri, 28 Aug 2026 14:34:15 +0900
Subject: [PATCH 25/25] fix(workspace): restore setup test contract
---
.../src/features/workspace/Workspace.test.tsx | 12 ++++++++----
apps/desktop/src/features/workspace/Workspace.tsx | 1 -
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 6e3eda7ab..6507d18a7 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -100,7 +100,7 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
const setupButton = screen.getByRole("button", {
- name: /Set up Bass Guitar: Keep the attack short so the verse breathes/i
+ name: /Set up Bass Guitar · then start in C#2–E3\. Setup: Keep the attack short so the verse breathes/i
}) as HTMLButtonElement;
expect(setupButton.disabled).toBe(false);
expect(screen.queryByRole("button", { name: "Transcribe Bass" })).toBeNull();
@@ -358,7 +358,7 @@ describe("Workspace", () => {
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
fireEvent.click(
screen.getByRole("button", {
- name: /Then start on A2 from 0:42 on tonight's groove map/i
+ name: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/i
})
);
@@ -398,6 +398,10 @@ describe("Workspace", () => {
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- expect(screen.getByRole("button", { name: /Bass Guitar 세팅: Keep the attack short so the verse breathes/ })).toBeTruthy();
+ expect(
+ screen.getByRole("button", {
+ name: /Bass Guitar 세팅 · .*세팅: Keep the attack short so the verse breathes/
+ })
+ ).toBeTruthy();
});
-});
\ No newline at end of file
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index da21172d7..d6b39520f 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -223,7 +223,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const hasPlayableRange = Boolean(roleRangeLow && roleRangeHigh);
const hasStartEvidence = Boolean(firstNote || hasPlayableRange);
const canArmTonightSetup = Boolean(setupCue && hasStartEvidence);
- const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(