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 - {canTranscribeBass ? ( + {canArmTonightSetup ? ( ) : ( )} @@ -416,13 +549,21 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
+

- {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 (
)}
@@ -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(