From f9e20b2040ee50d928005710e656f4b5c0ab7516 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 24 Sep 2026 13:59:30 +0000
Subject: [PATCH 1/6] perf: apply useCallback to handlePracticeProgressChange
---
.jules/bolt.md | 5 +++++
apps/desktop/src/features/workspace/Workspace.tsx | 6 +++---
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/.jules/bolt.md b/.jules/bolt.md
index d54cf10fc..1e5d1493e 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -61,3 +61,8 @@
## 2026-07-13 - Array.from mapping optimization
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
+
+## 2024-09-24 - Missing useCallback on handlers breaks React.memo
+
+**Learning:** In React, passing unmemoized event handler functions (like `handlePracticeProgressChange`) to a `React.memo`-wrapped child component (`PracticeProgress`) completely breaks its memoization, causing it to re-render every time the parent (`Workspace`) renders, even if its props semantically didn't change.
+**Action:** Always wrap event handlers passed as props to memoized components in `useCallback` to ensure referential stability across parent renders.
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..7a874e771 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, memo, type MouseEvent } from "react";
+import { useState, useMemo, useCallback, memo, type MouseEvent } from "react";
import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
@@ -165,7 +165,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
: t("workspaceFirstRangeMissing");
/** Handle the practice progress change internally by immutably updating the song state. */
- const handlePracticeProgressChange = (newProgress: number) => {
+ const handlePracticeProgressChange = useCallback((newProgress: number) => {
if (!activeRole || !onSongUpdate) return;
// Performance: Use shallow copying to avoid expensive structuredClone
@@ -189,7 +189,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
};
onSongUpdate(nextSong);
- };
+ }, [song, activeRole, onSongUpdate]);
const collaborationAssignments = useMemo(
() => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []),
[song.collaboration]
From 3d4b206baa82569a6d85eb2e3fd6d14313dd2045 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 24 Sep 2026 23:10:33 +0900
Subject: [PATCH 2/6] repair(perf): drop unsupported callback performance
doctrine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep the concrete Workspace callback-stability candidate while removing the generated repository-wide “always use useCallback” rule and unsupported performance claims. Canonical adoption remains #910-owned and measurement-bound.
---
.jules/bolt.md | 5 -----
1 file changed, 5 deletions(-)
diff --git a/.jules/bolt.md b/.jules/bolt.md
index 1e5d1493e..d54cf10fc 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -61,8 +61,3 @@
## 2026-07-13 - Array.from mapping optimization
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
-
-## 2024-09-24 - Missing useCallback on handlers breaks React.memo
-
-**Learning:** In React, passing unmemoized event handler functions (like `handlePracticeProgressChange`) to a `React.memo`-wrapped child component (`PracticeProgress`) completely breaks its memoization, causing it to re-render every time the parent (`Workspace`) renders, even if its props semantically didn't change.
-**Action:** Always wrap event handlers passed as props to memoized components in `useCallback` to ensure referential stability across parent renders.
From 653035dbba90f6d2851cca72f2b9ff159b8f9d1f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 25 Sep 2026 02:15:13 +0900
Subject: [PATCH 3/6] repair: restore canonical #910 tree in #1263
---
.jules/bolt.md | 4 +
AGENTS.md | 1 +
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 10 +
CLAUDE.md | 2 +
.../src/features/workspace/GrooveMap.test.tsx | 50 +++++
.../src/features/workspace/GrooveMap.tsx | 76 +++++--
.../workspace/Workspace.review.test.tsx | 192 +++++++++++++++++
.../src/features/workspace/Workspace.test.tsx | 94 ++++++++-
.../src/features/workspace/Workspace.tsx | 196 ++++++++++++++++--
apps/desktop/src/i18n/index.test.ts | 12 +-
apps/desktop/src/i18n/index.ts | 12 ++
apps/desktop/src/locales/en/common.json | 21 ++
apps/desktop/src/locales/ko/common.json | 21 ++
docs/design-system/component-contract.md | 2 +-
.../tests/test_release_metadata.py | 12 ++
.../tests/test_supply_chain_policy.py | 4 +-
17 files changed, 656 insertions(+), 56 deletions(-)
create mode 100644 apps/desktop/src/features/workspace/GrooveMap.test.tsx
create mode 100644 apps/desktop/src/features/workspace/Workspace.review.test.tsx
diff --git a/.jules/bolt.md b/.jules/bolt.md
index d54cf10fc..2a88db5bc 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -61,3 +61,7 @@
## 2026-07-13 - Array.from mapping optimization
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
+
+## 2026-09-05 - Min/max scans remain linear
+**Learning:** Replacing `Array.prototype.reduce()` with an indexed loop for an extremum search can remove callback dispatch, but both forms still scan every element and remain O(N). A simple `if (value > max)` is not automatically semantics-preserving: it ignores `NaN`, whereas `Math.max` propagates it. A microbenchmark on an arbitrary array does not establish buyer-path improvement.
+**Action:** Preserve the existing reduction semantics first, then profile representative GrooveMap transcription sizes in the actual Chromium/Electron render path before claiming material latency, heap, or GC gains.
diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..72e69fd45 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 earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..1aa737eed 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
@@ -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 34331fb86..6f6b386b5 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.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -15,7 +16,16 @@
### Fixed
+- Keep Groove Map loading truthfully indeterminate unless real progress exists, and expose Cancel only when a cancellation callback is actually available.
+- Keep disabled Stem Player controls discoverable by their visible labels for
+ assistive technology and speech input while retaining the translated
+ unavailable reason in each accessible name and tooltip.
+- Reject sentinel, malformed, and inverted setup ranges before they can enable
+ a buyer-visible rehearsal action or render as playable evidence.
+- Localize Groove Map states and the unavailable Loop control through the
+ owned translation boundary, with literal fail-closed placeholder handling.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
+- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range.
## [0.1.3] - 2026-04-29
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..5b9d65151 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 earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op.
+
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src/features/workspace/GrooveMap.test.tsx b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
new file mode 100644
index 000000000..723f0aac8
--- /dev/null
+++ b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { GrooveMap, maximumNoteOffset } from "./GrooveMap";
+
+describe("maximumNoteOffset", () => {
+ it("preserves the ten-second floor and finite maximum", () => {
+ expect(maximumNoteOffset([])).toBe(10);
+ expect(
+ maximumNoteOffset([
+ { onset: 0, offset: 1.5, pitch: "C4", velocity: 100 },
+ { onset: 1.5, offset: 12, pitch: "D4", velocity: 100 }
+ ])
+ ).toBe(12);
+ });
+
+ it("preserves Math.max non-finite semantics until shared admission rejects them", () => {
+ expect(
+ Number.isNaN(
+ maximumNoteOffset([
+ { onset: 0, offset: Number.NaN, pitch: "C4", velocity: 100 },
+ { onset: 1, offset: 12, pitch: "D4", velocity: 100 }
+ ])
+ )
+ ).toBe(true);
+ expect(
+ maximumNoteOffset([
+ { onset: 0, offset: Number.POSITIVE_INFINITY, pitch: "C4", velocity: 100 }
+ ])
+ ).toBe(Number.POSITIVE_INFINITY);
+ });
+});
+
+describe("GrooveMap maximum-offset geometry", () => {
+ it("uses an offset beyond the ten-second floor for rendered note geometry", () => {
+ render(
+
+ );
+
+ expect(screen.getByTitle("D4 (10.00s - 20.00s)")).toHaveStyle({
+ left: "50%",
+ width: "50%"
+ });
+ });
+});
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index 2745d4d79..ccb92b3d8 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -2,23 +2,36 @@ import { memo, useMemo } from "react";
import type { TranscriptionNote } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
const EMPTY_NOTES: TranscriptionNote[] = [];
-/** Documented. */
+/** Preserve the former reduction semantics while avoiding reducer callback dispatch. */
+function maximumNoteOffset(notes: readonly TranscriptionNote[]): number {
+ let max = 10;
+ for (let i = 0; i < notes.length; i++) {
+ // Keep Math.max here: shared timing admission does not yet reject every non-finite value.
+ // A simple `>` comparison would therefore silently change NaN handling at this UI boundary.
+ max = Math.max(max, notes[i]!.offset);
+ }
+ return max;
+}
+
+/** Inputs for the selected role's rehearsal groove map. */
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
+ entranceOnset?: number;
+ roleName: string;
+ onCancel?: () => void;
}
-/** Documented. */
-function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
+/** Render the selected role's transcription and optional first-entrance emphasis. */
+function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCancel }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;
+ const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- // Find max offset to determine timeline width
- const maxTime = useMemo(() => {
- return renderedNotes.reduce((max, n) => Math.max(max, n.offset), 10);
- }, [renderedNotes]);
+ const maxTime = useMemo(() => maximumNoteOffset(renderedNotes), [renderedNotes]);
// Unique pitches to determine vertical lanes (avoiding 88-key piano roll)
const uniquePitches = useMemo(() => {
@@ -36,6 +49,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
return map;
}, [uniquePitches]);
+ const entranceIndex = useMemo(() => {
+ if (entranceOnset === undefined) {
+ return -1;
+ }
+ return renderedNotes.findIndex((note) => note.onset === entranceOnset);
+ }, [entranceOnset, renderedNotes]);
+
if (isLoading) {
return (
- Checking the bass line... 45%
+ {fillTranslation(t("grooveMapLoading"), { role: roleName })}
-
- Cancel
-
+ {onCancel ? (
+
+ {t("grooveMapCancel")}
+
+ ) : null}
);
}
if (renderedNotes.length === 0) {
return (
-
- No bass line transcription yet. Use it when you want to check the groove before rehearsal.
+
+ {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
);
}
@@ -68,13 +94,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
role="region"
tabIndex={0}
- aria-label="Bass transcription groove map"
+ aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })}
>
- Transcription complete. {renderedNotes.length} notes analyzed.
+ {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}
- {renderedNotes.length} notes mapped for rehearsal
+ {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}
@@ -95,20 +121,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const leftPercent = (note.onset / maxTime) * 100;
const widthPercent = ((note.offset - note.onset) / maxTime) * 100;
const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`;
+ const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset;
return (
- {noteLabel}
+ {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
);
@@ -120,4 +152,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const GrooveMap = memo(GrooveMapComponent);
-export { GrooveMap };
+export { GrooveMap, maximumNoteOffset };
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..f264dacb1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -0,0 +1,192 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { GrooveMap } from "./GrooveMap";
+import { Workspace } from "./Workspace";
+
+/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */
+function replaceRole(song: ReturnType
, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) {
+ song.sections = song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role))
+ }));
+}
+
+describe("Workspace review regressions", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
+ const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
+ expect(source).not.toContain("new RegExp(");
+ });
+
+ it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
+ render(
+
+ );
+
+ 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 without inventing progress", () => {
+ 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...")).toBeTruthy();
+ });
+
+ it("does not expose a cancel action without cancellation capability and invokes it when provided", () => {
+ const onCancel = vi.fn();
+ const { rerender } = render( );
+
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+
+ rerender( );
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("localizes GrooveMap states and the unavailable loop control", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+ const song = createDemoRehearsalSong();
+ const roleName = song.sections[0]!.roles[0]!.name;
+ const { rerender } = render( );
+
+ expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy();
+
+ rerender( {}} />);
+ expect(screen.getByText(`${roleName} 파트를 확인하는 중...`)).toBeTruthy();
+ expect(screen.getByRole("button", { name: "취소" })).toBeTruthy();
+
+ rerender( );
+ fireEvent.click(screen.getByRole("tab", { name: roleName }));
+ expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복");
+ });
+
+ it("keeps range-backed setup available when no exact first note exists", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: "C#2",
+ highestNote: "E3"
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+
+ const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i });
+ expect(setupButton).toBeEnabled();
+ const visibleLabel = setupButton.textContent?.trim() ?? "";
+ expect(visibleLabel).not.toBe("");
+ expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel);
+ });
+
+ it("keeps placeholder-looking role names literal in setup copy", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ name: "{low}",
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: "C#2",
+ highestNote: "E3"
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
+
+ expect(
+ screen.getByRole("button", {
+ name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map"
+ })
+ ).toBeEnabled();
+ });
+
+ it("keeps disabled stem controls discoverable by their visible labels", () => {
+ const song = createDemoRehearsalSong();
+
+ render( );
+
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+ expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem");
+ expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent(
+ "Solo / mute others"
+ );
+ });
+
+ it("natively disables setup when a cue has neither an entrance nor a playable range", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: " ",
+ highestNote: " "
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+
+ const setupButton = screen.getByRole("button", {
+ name: "No first entrance or playable range yet. Stay on tonight's map."
+ });
+ expect(setupButton).toBeDisabled();
+ });
+
+ it.each([
+ ["none", "none"],
+ ["E3", "C#2"],
+ ["low", "high"]
+ ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote,
+ highestNote
+ }
+ }));
+
+ render( );
+ 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();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..38e439216 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 · 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();
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
@@ -115,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();
@@ -326,4 +331,81 @@ 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: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/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: "Play stem. 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).toBeDisabled();
+ 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 7a874e771..3ee2c352e 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,8 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
-import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale } from "../../i18n";
+import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -41,6 +41,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi
}
type Translator = ReturnType;
+type TranscriptionNote = NonNullable[number];
/** Documented. */
function preventUnavailableAction(event: MouseEvent): void {
@@ -58,6 +59,47 @@ 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();
+}
+
+/** 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) {
@@ -121,6 +163,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
@@ -150,7 +193,33 @@ 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 activeRoleRange = playableRange(
+ activeRoleDetails?.range.lowestNote,
+ activeRoleDetails?.range.highestNote
+ );
+ const roleRangeLow = activeRoleRange?.lowestNote;
+ const roleRangeHigh = activeRoleRange?.highestNote;
+ const setupCue = roleSetupCue(activeRoleDetails);
+ const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
+ const hasPlayableRange = activeRoleRange !== null;
+ const hasStartEvidence = Boolean(firstNote || hasPlayableRange);
+ const canArmTonightSetup = Boolean(setupCue && hasStartEvidence);
const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(
@@ -225,6 +294,66 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
+ const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
+ const setupUnavailableLabel = setupCue && !hasStartEvidence
+ ? t("workspaceSetupStartUnavailable")
+ : t("workspaceSetupUnavailable");
+ const setupActionLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
+ : firstNote
+ ? fillTranslation(t("workspaceSetupActionWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset)
+ })
+ : fillTranslation(t("workspaceSetupActionWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!
+ });
+ const setupAriaLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
+ : firstNote
+ ? fillTranslation(t("workspaceSetupAriaWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupSentenceCue
+ })
+ : fillTranslation(t("workspaceSetupAriaWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
+ setup: setupSentenceCue
+ });
+ const setupStatus = firstNote
+ ? fillTranslation(t("workspaceSetupArmedWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupSentenceCue
+ })
+ : fillTranslation(t("workspaceSetupArmedWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
+ setup: setupSentenceCue
+ });
+
+ /** 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 = () => {
@@ -364,7 +493,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -376,8 +505,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- Loop section
+ {t("workspaceLoopAction")}
Solo / mute others
- {canTranscribeBass ? (
+ {canArmTonightSetup ? (
- Transcribe Bass
+ {setupActionLabel}
) : (
- Transcribe Bass
+ {setupUnavailableLabel}
)}
@@ -438,14 +569,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
-
+
{t("workspaceTranspositionLabel")}
- {roleTranspositionPlan}
+ {setupCue ?? roleTranspositionPlan}
+ {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? (
+
{roleTranspositionPlan}
+ ) : null}
{song.collaboration && (
@@ -498,7 +640,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
-
+ {armedSetupRoleId === activeRole ? (
+
+ {setupStatus}
+
+ ) : null}
+
)}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..a0a1369c1 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { createTranslator, detectPreferredLocale } from "./index";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "./index";
import koCommon from "../locales/ko/common.json";
describe("i18n", () => {
@@ -75,4 +75,14 @@ describe("i18n", () => {
}
});
});
+
+ describe("fillTranslation", () => {
+ it("keeps unknown placeholders and placeholder-shaped values literal", () => {
+ expect(
+ fillTranslation("Set up {role} in {range}", {
+ role: "{range}"
+ })
+ ).toBe("Set up {range} in {range}");
+ });
+ });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..0dcf9ad30 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -6,6 +6,8 @@ export type Locale = "en" | "ko";
/** Documented. */
export type TranslationKey = keyof typeof enCommon;
+const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
+
const dictionaries = {
en: enCommon,
ko: koCommon
@@ -18,6 +20,16 @@ export function createTranslator(locale: Locale = "en") {
};
}
+/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */
+export function fillTranslation(
+ template: string,
+ values: Readonly>
+): string {
+ return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) =>
+ Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder
+ );
+}
+
/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..0c6f636f7 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -50,6 +50,27 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
+ "workspaceThisRole": "This part",
+ "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
+ "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
+ "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",
+ "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.",
+ "workspaceLoopAction": "Loop section",
+ "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.",
+ "grooveMapLoading": "Checking the {role} line...",
+ "grooveMapCancel": "Cancel",
+ "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.",
+ "grooveMapRegionLabel": "{role} transcription groove map",
+ "grooveMapComplete": "Transcription complete. {count} notes analyzed.",
+ "grooveMapMapped": "{count} notes mapped for rehearsal",
+ "grooveMapEntranceTitle": "Tonight's entrance · {note}",
+ "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}",
"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 0f6c6c66d..34a0147cd 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -50,6 +50,27 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
+ "workspaceThisRole": "이 파트",
+ "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
+ "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
+ "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요",
+ "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요",
+ "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
+ "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
+ "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
+ "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.",
+ "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
+ "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
+ "workspaceLoopAction": "구간 반복",
+ "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.",
+ "grooveMapLoading": "{role} 파트를 확인하는 중...",
+ "grooveMapCancel": "취소",
+ "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.",
+ "grooveMapRegionLabel": "{role} 채보 그루브 맵",
+ "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.",
+ "grooveMapMapped": "합주용 음표 {count}개 매핑됨",
+ "grooveMapEntranceTitle": "오늘의 진입점 · {note}",
+ "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..15aeafa92 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 required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, optional `entranceOnset`, and `onCancel` only when a real cancellation capability exists. Loading copy must not invent a progress percentage. 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()`. |
diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py
index ec6b39a32..370d556c7 100644
--- a/services/analysis-engine/tests/test_release_metadata.py
+++ b/services/analysis-engine/tests/test_release_metadata.py
@@ -62,6 +62,18 @@ 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")
+ release_marker = "## [0.1.3]"
+ assert release_marker in changelog
+ unreleased = changelog.split(release_marker, 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()
diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py
index 1d8224c5a..6a0853944 100644
--- a/services/analysis-engine/tests/test_supply_chain_policy.py
+++ b/services/analysis-engine/tests/test_supply_chain_policy.py
@@ -1275,9 +1275,7 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None:
workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8")
assert "concurrency:" in workflow, workflow_name
assert "cancel-in-progress: false" in workflow, workflow_name
- assert "contents: read" in workflow or "permissions: read-all" in workflow, (
- workflow_name
- )
+ assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name
assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")
From 8296d7ffc6787395a2c5a574ba13adfafb9a9f61 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 25 Sep 2026 00:01:33 +0000
Subject: [PATCH 4/6] perf: apply useCallback to handlePracticeProgressChange
---
.jules/bolt.md | 7 +-
AGENTS.md | 1 -
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 10 -
CLAUDE.md | 2 -
.../src/features/workspace/GrooveMap.test.tsx | 50 -----
.../src/features/workspace/GrooveMap.tsx | 76 ++-----
.../workspace/Workspace.review.test.tsx | 192 -----------------
.../src/features/workspace/Workspace.test.tsx | 94 +--------
.../src/features/workspace/Workspace.tsx | 196 ++----------------
apps/desktop/src/i18n/index.test.ts | 12 +-
apps/desktop/src/i18n/index.ts | 12 --
apps/desktop/src/locales/en/common.json | 21 --
apps/desktop/src/locales/ko/common.json | 21 --
docs/design-system/component-contract.md | 2 +-
.../tests/test_release_metadata.py | 12 --
.../tests/test_supply_chain_policy.py | 4 +-
17 files changed, 60 insertions(+), 655 deletions(-)
delete mode 100644 apps/desktop/src/features/workspace/GrooveMap.test.tsx
delete mode 100644 apps/desktop/src/features/workspace/Workspace.review.test.tsx
diff --git a/.jules/bolt.md b/.jules/bolt.md
index 2a88db5bc..1e5d1493e 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -62,6 +62,7 @@
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
-## 2026-09-05 - Min/max scans remain linear
-**Learning:** Replacing `Array.prototype.reduce()` with an indexed loop for an extremum search can remove callback dispatch, but both forms still scan every element and remain O(N). A simple `if (value > max)` is not automatically semantics-preserving: it ignores `NaN`, whereas `Math.max` propagates it. A microbenchmark on an arbitrary array does not establish buyer-path improvement.
-**Action:** Preserve the existing reduction semantics first, then profile representative GrooveMap transcription sizes in the actual Chromium/Electron render path before claiming material latency, heap, or GC gains.
+## 2024-09-24 - Missing useCallback on handlers breaks React.memo
+
+**Learning:** In React, passing unmemoized event handler functions (like `handlePracticeProgressChange`) to a `React.memo`-wrapped child component (`PracticeProgress`) completely breaks its memoization, causing it to re-render every time the parent (`Workspace`) renders, even if its props semantically didn't change.
+**Action:** Always wrap event handlers passed as props to memoized components in `useCallback` to ensure referential stability across parent renders.
diff --git a/AGENTS.md b/AGENTS.md
index 72e69fd45..b9a67ce17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,6 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
-- After a part is selected, the role strip must name tonight's setup from `setupNote` / transposition / simplification and point at the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1aa737eed..ca0df5ac4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-08-18
+Last updated: 2026-03-11
## Brand source
@@ -68,7 +68,6 @@ Last updated: 2026-08-18
- 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 6f6b386b5..34331fb86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,6 @@
### Added
-- The ready workspace can set up tonight's selected part from the analyzed setup cue and name the first entrance on the groove map, instead of leaving `Transcribe Bass` inert.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -16,16 +15,7 @@
### Fixed
-- Keep Groove Map loading truthfully indeterminate unless real progress exists, and expose Cancel only when a cancellation callback is actually available.
-- Keep disabled Stem Player controls discoverable by their visible labels for
- assistive technology and speech input while retaining the translated
- unavailable reason in each accessible name and tooltip.
-- Reject sentinel, malformed, and inverted setup ranges before they can enable
- a buyer-visible rehearsal action or render as playable evidence.
-- Localize Groove Map states and the unavailable Loop control through the
- owned translation boundary, with literal fail-closed placeholder handling.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
-- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range.
## [0.1.3] - 2026-04-29
diff --git a/CLAUDE.md b/CLAUDE.md
index 5b9d65151..b5a34c1fa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,8 +6,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.
-After a part is selected, the role-strip setup control must name tonight's setup cue and the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op.
-
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src/features/workspace/GrooveMap.test.tsx b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
deleted file mode 100644
index 723f0aac8..000000000
--- a/apps/desktop/src/features/workspace/GrooveMap.test.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { GrooveMap, maximumNoteOffset } from "./GrooveMap";
-
-describe("maximumNoteOffset", () => {
- it("preserves the ten-second floor and finite maximum", () => {
- expect(maximumNoteOffset([])).toBe(10);
- expect(
- maximumNoteOffset([
- { onset: 0, offset: 1.5, pitch: "C4", velocity: 100 },
- { onset: 1.5, offset: 12, pitch: "D4", velocity: 100 }
- ])
- ).toBe(12);
- });
-
- it("preserves Math.max non-finite semantics until shared admission rejects them", () => {
- expect(
- Number.isNaN(
- maximumNoteOffset([
- { onset: 0, offset: Number.NaN, pitch: "C4", velocity: 100 },
- { onset: 1, offset: 12, pitch: "D4", velocity: 100 }
- ])
- )
- ).toBe(true);
- expect(
- maximumNoteOffset([
- { onset: 0, offset: Number.POSITIVE_INFINITY, pitch: "C4", velocity: 100 }
- ])
- ).toBe(Number.POSITIVE_INFINITY);
- });
-});
-
-describe("GrooveMap maximum-offset geometry", () => {
- it("uses an offset beyond the ten-second floor for rendered note geometry", () => {
- render(
-
- );
-
- expect(screen.getByTitle("D4 (10.00s - 20.00s)")).toHaveStyle({
- left: "50%",
- width: "50%"
- });
- });
-});
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index ccb92b3d8..2745d4d79 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -2,36 +2,23 @@ import { memo, useMemo } from "react";
import type { TranscriptionNote } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
const EMPTY_NOTES: TranscriptionNote[] = [];
-/** Preserve the former reduction semantics while avoiding reducer callback dispatch. */
-function maximumNoteOffset(notes: readonly TranscriptionNote[]): number {
- let max = 10;
- for (let i = 0; i < notes.length; i++) {
- // Keep Math.max here: shared timing admission does not yet reject every non-finite value.
- // A simple `>` comparison would therefore silently change NaN handling at this UI boundary.
- max = Math.max(max, notes[i]!.offset);
- }
- return max;
-}
-
-/** Inputs for the selected role's rehearsal groove map. */
+/** Documented. */
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
- entranceOnset?: number;
- roleName: string;
- onCancel?: () => void;
}
-/** Render the selected role's transcription and optional first-entrance emphasis. */
-function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCancel }: GrooveMapProps) {
+/** Documented. */
+function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;
- const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const maxTime = useMemo(() => maximumNoteOffset(renderedNotes), [renderedNotes]);
+ // Find max offset to determine timeline width
+ const maxTime = useMemo(() => {
+ return renderedNotes.reduce((max, n) => Math.max(max, n.offset), 10);
+ }, [renderedNotes]);
// Unique pitches to determine vertical lanes (avoiding 88-key piano roll)
const uniquePitches = useMemo(() => {
@@ -49,13 +36,6 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
return map;
}, [uniquePitches]);
- const entranceIndex = useMemo(() => {
- if (entranceOnset === undefined) {
- return -1;
- }
- return renderedNotes.findIndex((note) => note.onset === entranceOnset);
- }, [entranceOnset, renderedNotes]);
-
if (isLoading) {
return (
- {fillTranslation(t("grooveMapLoading"), { role: roleName })}
+ Checking the bass line... 45%
- {onCancel ? (
-
- {t("grooveMapCancel")}
-
- ) : null}
+
+ Cancel
+
);
}
if (renderedNotes.length === 0) {
return (
-
- {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
+
+ No bass line transcription yet. Use it when you want to check the groove before rehearsal.
);
}
@@ -94,13 +68,13 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
role="region"
tabIndex={0}
- aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })}
+ aria-label="Bass transcription groove map"
>
- {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}
+ Transcription complete. {renderedNotes.length} notes analyzed.
- {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}
+ {renderedNotes.length} notes mapped for rehearsal
@@ -121,26 +95,20 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
const leftPercent = (note.onset / maxTime) * 100;
const widthPercent = ((note.offset - note.onset) / maxTime) * 100;
const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`;
- const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset;
return (
- {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
+ {noteLabel}
);
@@ -152,4 +120,4 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
const GrooveMap = memo(GrooveMapComponent);
-export { GrooveMap, maximumNoteOffset };
+export { GrooveMap };
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
deleted file mode 100644
index f264dacb1..000000000
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import { readFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong } from "@bandscope/shared-types";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { GrooveMap } from "./GrooveMap";
-import { Workspace } from "./Workspace";
-
-/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */
-function replaceRole(song: ReturnType
, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) {
- song.sections = song.sections.map((section) => ({
- ...section,
- roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role))
- }));
-}
-
-describe("Workspace review regressions", () => {
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
- const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
- expect(source).not.toContain("new RegExp(");
- });
-
- it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
- render(
-
- );
-
- 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 without inventing progress", () => {
- 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...")).toBeTruthy();
- });
-
- it("does not expose a cancel action without cancellation capability and invokes it when provided", () => {
- const onCancel = vi.fn();
- const { rerender } = render( );
-
- expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
-
- rerender( );
- fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
-
- expect(onCancel).toHaveBeenCalledTimes(1);
- });
-
- it("localizes GrooveMap states and the unavailable loop control", () => {
- vi.stubGlobal("navigator", { language: "ko-KR" });
- const song = createDemoRehearsalSong();
- const roleName = song.sections[0]!.roles[0]!.name;
- const { rerender } = render( );
-
- expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy();
-
- rerender( {}} />);
- expect(screen.getByText(`${roleName} 파트를 확인하는 중...`)).toBeTruthy();
- expect(screen.getByRole("button", { name: "취소" })).toBeTruthy();
-
- rerender( );
- fireEvent.click(screen.getByRole("tab", { name: roleName }));
- expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복");
- });
-
- it("keeps range-backed setup available when no exact first note exists", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: "C#2",
- highestNote: "E3"
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
-
- const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i });
- expect(setupButton).toBeEnabled();
- const visibleLabel = setupButton.textContent?.trim() ?? "";
- expect(visibleLabel).not.toBe("");
- expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel);
- });
-
- it("keeps placeholder-looking role names literal in setup copy", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- name: "{low}",
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: "C#2",
- highestNote: "E3"
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
-
- expect(
- screen.getByRole("button", {
- name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map"
- })
- ).toBeEnabled();
- });
-
- it("keeps disabled stem controls discoverable by their visible labels", () => {
- const song = createDemoRehearsalSong();
-
- render( );
-
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
- expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem");
- expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent(
- "Solo / mute others"
- );
- });
-
- it("natively disables setup when a cue has neither an entrance nor a playable range", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: " ",
- highestNote: " "
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
-
- const setupButton = screen.getByRole("button", {
- name: "No first entrance or playable range yet. Stay on tonight's map."
- });
- expect(setupButton).toBeDisabled();
- });
-
- it.each([
- ["none", "none"],
- ["E3", "C#2"],
- ["low", "high"]
- ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote,
- highestNote
- }
- }));
-
- render( );
- 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();
- });
-});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 38e439216..7837bf80e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -8,7 +8,6 @@ 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", {
@@ -29,7 +28,6 @@ describe("Workspace", () => {
configurable: true,
value: originalRevokeObjectUrl
});
- HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it("updates practice progress immutably through onSongUpdate", () => {
@@ -87,23 +85,20 @@ describe("Workspace", () => {
expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
- it("enables tonight's setup from the role setup cue rather than the role name", () => {
+ it("enables bass transcription from selected role metadata rather than role id text", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "low-end",
- name: "Bass Guitar",
- setupNote: "Keep the attack short so the verse breathes."
+ name: "Bass Guitar"
};
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const setupButton = screen.getByRole("button", {
- 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();
+ const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement;
+ expect(transcribeButton.disabled).toBe(false);
+ expect(transcribeButton.title).toBe("Transcribe part");
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
@@ -120,7 +115,7 @@ describe("Workspace", () => {
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const grooveMap = screen.getByRole("region", { name: /bass guitar transcription groove map/i });
+ const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i });
expect(grooveMap.className).toContain("bg-slate-950");
expect(screen.getByText("E2")).toBeTruthy();
expect(screen.getByText("G2")).toBeTruthy();
@@ -331,81 +326,4 @@ 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: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/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: "Play stem. 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).toBeDisabled();
- 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 3ee2c352e..7a874e771 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,8 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
-import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
+import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -41,7 +41,6 @@ function downloadTextFile(contents: string, type: string, filename: string): voi
}
type Translator = ReturnType;
-type TranscriptionNote = NonNullable[number];
/** Documented. */
function preventUnavailableAction(event: MouseEvent): void {
@@ -59,47 +58,6 @@ 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();
-}
-
-/** 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) {
@@ -163,7 +121,6 @@ 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
@@ -193,33 +150,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
if (!activeRole) return undefined;
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
- 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 activeRoleRange = playableRange(
- activeRoleDetails?.range.lowestNote,
- activeRoleDetails?.range.highestNote
- );
- const roleRangeLow = activeRoleRange?.lowestNote;
- const roleRangeHigh = activeRoleRange?.highestNote;
- const setupCue = roleSetupCue(activeRoleDetails);
- const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
- const hasPlayableRange = activeRoleRange !== null;
- 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(
@@ -294,66 +225,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
- const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
- const setupUnavailableLabel = setupCue && !hasStartEvidence
- ? t("workspaceSetupStartUnavailable")
- : t("workspaceSetupUnavailable");
- const setupActionLabel = !canArmTonightSetup
- ? setupUnavailableLabel
- : firstNote
- ? fillTranslation(t("workspaceSetupActionWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset)
- })
- : fillTranslation(t("workspaceSetupActionWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!
- });
- const setupAriaLabel = !canArmTonightSetup
- ? setupUnavailableLabel
- : firstNote
- ? fillTranslation(t("workspaceSetupAriaWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset),
- setup: setupSentenceCue
- })
- : fillTranslation(t("workspaceSetupAriaWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!,
- setup: setupSentenceCue
- });
- const setupStatus = firstNote
- ? fillTranslation(t("workspaceSetupArmedWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset),
- setup: setupSentenceCue
- })
- : fillTranslation(t("workspaceSetupArmedWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!,
- setup: setupSentenceCue
- });
-
- /** 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 = () => {
@@ -493,7 +364,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -505,8 +376,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {t("workspaceLoopAction")}
+ Loop section
Solo / mute others
- {canArmTonightSetup ? (
+ {canTranscribeBass ? (
- {setupActionLabel}
+ Transcribe Bass
) : (
- {setupUnavailableLabel}
+ Transcribe Bass
)}
@@ -569,25 +438,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
-
+
{t("workspaceTranspositionLabel")}
- {setupCue ?? roleTranspositionPlan}
+ {roleTranspositionPlan}
- {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? (
-
{roleTranspositionPlan}
- ) : null}
{song.collaboration && (
@@ -640,17 +498,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
- {armedSetupRoleId === activeRole ? (
-
- {setupStatus}
-
- ) : null}
-
+
)}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index a0a1369c1..dc49a0a25 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "./index";
+import { createTranslator, detectPreferredLocale } from "./index";
import koCommon from "../locales/ko/common.json";
describe("i18n", () => {
@@ -75,14 +75,4 @@ describe("i18n", () => {
}
});
});
-
- describe("fillTranslation", () => {
- it("keeps unknown placeholders and placeholder-shaped values literal", () => {
- expect(
- fillTranslation("Set up {role} in {range}", {
- role: "{range}"
- })
- ).toBe("Set up {range} in {range}");
- });
- });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 0dcf9ad30..1a9f471f0 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -6,8 +6,6 @@ export type Locale = "en" | "ko";
/** Documented. */
export type TranslationKey = keyof typeof enCommon;
-const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
-
const dictionaries = {
en: enCommon,
ko: koCommon
@@ -20,16 +18,6 @@ export function createTranslator(locale: Locale = "en") {
};
}
-/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */
-export function fillTranslation(
- template: string,
- values: Readonly>
-): string {
- return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) =>
- Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder
- );
-}
-
/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 0c6f636f7..d803a765e 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -50,27 +50,6 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
- "workspaceThisRole": "This part",
- "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
- "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
- "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",
- "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.",
- "workspaceLoopAction": "Loop section",
- "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.",
- "grooveMapLoading": "Checking the {role} line...",
- "grooveMapCancel": "Cancel",
- "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.",
- "grooveMapRegionLabel": "{role} transcription groove map",
- "grooveMapComplete": "Transcription complete. {count} notes analyzed.",
- "grooveMapMapped": "{count} notes mapped for rehearsal",
- "grooveMapEntranceTitle": "Tonight's entrance · {note}",
- "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}",
"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 34a0147cd..0f6c6c66d 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -50,27 +50,6 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
- "workspaceThisRole": "이 파트",
- "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
- "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
- "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요",
- "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요",
- "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
- "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
- "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
- "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.",
- "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
- "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
- "workspaceLoopAction": "구간 반복",
- "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.",
- "grooveMapLoading": "{role} 파트를 확인하는 중...",
- "grooveMapCancel": "취소",
- "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.",
- "grooveMapRegionLabel": "{role} 채보 그루브 맵",
- "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.",
- "grooveMapMapped": "합주용 음표 {count}개 매핑됨",
- "grooveMapEntranceTitle": "오늘의 진입점 · {note}",
- "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 15aeafa92..22602c313 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 required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, optional `entranceOnset`, and `onCancel` only when a real cancellation capability exists. Loading copy must not invent a progress percentage. Preserve scrollable region semantics, focus-visible styling, 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[]` and `isLoading?: boolean`; 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()`. |
diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py
index 370d556c7..ec6b39a32 100644
--- a/services/analysis-engine/tests/test_release_metadata.py
+++ b/services/analysis-engine/tests/test_release_metadata.py
@@ -62,18 +62,6 @@ 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")
- release_marker = "## [0.1.3]"
- assert release_marker in changelog
- unreleased = changelog.split(release_marker, 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()
diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py
index 6a0853944..1d8224c5a 100644
--- a/services/analysis-engine/tests/test_supply_chain_policy.py
+++ b/services/analysis-engine/tests/test_supply_chain_policy.py
@@ -1275,7 +1275,9 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None:
workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8")
assert "concurrency:" in workflow, workflow_name
assert "cancel-in-progress: false" in workflow, workflow_name
- assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name
+ assert "contents: read" in workflow or "permissions: read-all" in workflow, (
+ workflow_name
+ )
assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")
From bb111b3a0cc7e0b4bb384f36cbaf8df5bd82757e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 25 Sep 2026 09:04:46 +0900
Subject: [PATCH 5/6] repair: restore canonical #910 tree after #1263
recurrence
---
.jules/bolt.md | 7 +-
AGENTS.md | 1 +
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 10 +
CLAUDE.md | 2 +
.../src/features/workspace/GrooveMap.test.tsx | 50 +++++
.../src/features/workspace/GrooveMap.tsx | 76 +++++--
.../workspace/Workspace.review.test.tsx | 192 +++++++++++++++++
.../src/features/workspace/Workspace.test.tsx | 94 ++++++++-
.../src/features/workspace/Workspace.tsx | 196 ++++++++++++++++--
apps/desktop/src/i18n/index.test.ts | 12 +-
apps/desktop/src/i18n/index.ts | 12 ++
apps/desktop/src/locales/en/common.json | 21 ++
apps/desktop/src/locales/ko/common.json | 21 ++
docs/design-system/component-contract.md | 2 +-
.../tests/test_release_metadata.py | 12 ++
.../tests/test_supply_chain_policy.py | 4 +-
17 files changed, 655 insertions(+), 60 deletions(-)
create mode 100644 apps/desktop/src/features/workspace/GrooveMap.test.tsx
create mode 100644 apps/desktop/src/features/workspace/Workspace.review.test.tsx
diff --git a/.jules/bolt.md b/.jules/bolt.md
index 1e5d1493e..2a88db5bc 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -62,7 +62,6 @@
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
-## 2024-09-24 - Missing useCallback on handlers breaks React.memo
-
-**Learning:** In React, passing unmemoized event handler functions (like `handlePracticeProgressChange`) to a `React.memo`-wrapped child component (`PracticeProgress`) completely breaks its memoization, causing it to re-render every time the parent (`Workspace`) renders, even if its props semantically didn't change.
-**Action:** Always wrap event handlers passed as props to memoized components in `useCallback` to ensure referential stability across parent renders.
+## 2026-09-05 - Min/max scans remain linear
+**Learning:** Replacing `Array.prototype.reduce()` with an indexed loop for an extremum search can remove callback dispatch, but both forms still scan every element and remain O(N). A simple `if (value > max)` is not automatically semantics-preserving: it ignores `NaN`, whereas `Math.max` propagates it. A microbenchmark on an arbitrary array does not establish buyer-path improvement.
+**Action:** Preserve the existing reduction semantics first, then profile representative GrooveMap transcription sizes in the actual Chromium/Electron render path before claiming material latency, heap, or GC gains.
diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..72e69fd45 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 earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..1aa737eed 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
@@ -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 34331fb86..6f6b386b5 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.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -15,7 +16,16 @@
### Fixed
+- Keep Groove Map loading truthfully indeterminate unless real progress exists, and expose Cancel only when a cancellation callback is actually available.
+- Keep disabled Stem Player controls discoverable by their visible labels for
+ assistive technology and speech input while retaining the translated
+ unavailable reason in each accessible name and tooltip.
+- Reject sentinel, malformed, and inverted setup ranges before they can enable
+ a buyer-visible rehearsal action or render as playable evidence.
+- Localize Groove Map states and the unavailable Loop control through the
+ owned translation boundary, with literal fail-closed placeholder handling.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
+- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range.
## [0.1.3] - 2026-04-29
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..5b9d65151 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 earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op.
+
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src/features/workspace/GrooveMap.test.tsx b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
new file mode 100644
index 000000000..723f0aac8
--- /dev/null
+++ b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { GrooveMap, maximumNoteOffset } from "./GrooveMap";
+
+describe("maximumNoteOffset", () => {
+ it("preserves the ten-second floor and finite maximum", () => {
+ expect(maximumNoteOffset([])).toBe(10);
+ expect(
+ maximumNoteOffset([
+ { onset: 0, offset: 1.5, pitch: "C4", velocity: 100 },
+ { onset: 1.5, offset: 12, pitch: "D4", velocity: 100 }
+ ])
+ ).toBe(12);
+ });
+
+ it("preserves Math.max non-finite semantics until shared admission rejects them", () => {
+ expect(
+ Number.isNaN(
+ maximumNoteOffset([
+ { onset: 0, offset: Number.NaN, pitch: "C4", velocity: 100 },
+ { onset: 1, offset: 12, pitch: "D4", velocity: 100 }
+ ])
+ )
+ ).toBe(true);
+ expect(
+ maximumNoteOffset([
+ { onset: 0, offset: Number.POSITIVE_INFINITY, pitch: "C4", velocity: 100 }
+ ])
+ ).toBe(Number.POSITIVE_INFINITY);
+ });
+});
+
+describe("GrooveMap maximum-offset geometry", () => {
+ it("uses an offset beyond the ten-second floor for rendered note geometry", () => {
+ render(
+
+ );
+
+ expect(screen.getByTitle("D4 (10.00s - 20.00s)")).toHaveStyle({
+ left: "50%",
+ width: "50%"
+ });
+ });
+});
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index 2745d4d79..ccb92b3d8 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -2,23 +2,36 @@ import { memo, useMemo } from "react";
import type { TranscriptionNote } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
const EMPTY_NOTES: TranscriptionNote[] = [];
-/** Documented. */
+/** Preserve the former reduction semantics while avoiding reducer callback dispatch. */
+function maximumNoteOffset(notes: readonly TranscriptionNote[]): number {
+ let max = 10;
+ for (let i = 0; i < notes.length; i++) {
+ // Keep Math.max here: shared timing admission does not yet reject every non-finite value.
+ // A simple `>` comparison would therefore silently change NaN handling at this UI boundary.
+ max = Math.max(max, notes[i]!.offset);
+ }
+ return max;
+}
+
+/** Inputs for the selected role's rehearsal groove map. */
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
+ entranceOnset?: number;
+ roleName: string;
+ onCancel?: () => void;
}
-/** Documented. */
-function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
+/** Render the selected role's transcription and optional first-entrance emphasis. */
+function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCancel }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;
+ const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- // Find max offset to determine timeline width
- const maxTime = useMemo(() => {
- return renderedNotes.reduce((max, n) => Math.max(max, n.offset), 10);
- }, [renderedNotes]);
+ const maxTime = useMemo(() => maximumNoteOffset(renderedNotes), [renderedNotes]);
// Unique pitches to determine vertical lanes (avoiding 88-key piano roll)
const uniquePitches = useMemo(() => {
@@ -36,6 +49,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
return map;
}, [uniquePitches]);
+ const entranceIndex = useMemo(() => {
+ if (entranceOnset === undefined) {
+ return -1;
+ }
+ return renderedNotes.findIndex((note) => note.onset === entranceOnset);
+ }, [entranceOnset, renderedNotes]);
+
if (isLoading) {
return (
- Checking the bass line... 45%
+ {fillTranslation(t("grooveMapLoading"), { role: roleName })}
-
- Cancel
-
+ {onCancel ? (
+
+ {t("grooveMapCancel")}
+
+ ) : null}
);
}
if (renderedNotes.length === 0) {
return (
-
- No bass line transcription yet. Use it when you want to check the groove before rehearsal.
+
+ {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
);
}
@@ -68,13 +94,13 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
role="region"
tabIndex={0}
- aria-label="Bass transcription groove map"
+ aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })}
>
- Transcription complete. {renderedNotes.length} notes analyzed.
+ {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}
- {renderedNotes.length} notes mapped for rehearsal
+ {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}
@@ -95,20 +121,26 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const leftPercent = (note.onset / maxTime) * 100;
const widthPercent = ((note.offset - note.onset) / maxTime) * 100;
const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`;
+ const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset;
return (
- {noteLabel}
+ {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
);
@@ -120,4 +152,4 @@ function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const GrooveMap = memo(GrooveMapComponent);
-export { GrooveMap };
+export { GrooveMap, maximumNoteOffset };
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..f264dacb1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
@@ -0,0 +1,192 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { GrooveMap } from "./GrooveMap";
+import { Workspace } from "./Workspace";
+
+/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */
+function replaceRole(song: ReturnType
, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) {
+ song.sections = song.sections.map((section) => ({
+ ...section,
+ roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role))
+ }));
+}
+
+describe("Workspace review regressions", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
+ const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
+ expect(source).not.toContain("new RegExp(");
+ });
+
+ it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
+ render(
+
+ );
+
+ 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 without inventing progress", () => {
+ 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...")).toBeTruthy();
+ });
+
+ it("does not expose a cancel action without cancellation capability and invokes it when provided", () => {
+ const onCancel = vi.fn();
+ const { rerender } = render( );
+
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+
+ rerender( );
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("localizes GrooveMap states and the unavailable loop control", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+ const song = createDemoRehearsalSong();
+ const roleName = song.sections[0]!.roles[0]!.name;
+ const { rerender } = render( );
+
+ expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy();
+
+ rerender( {}} />);
+ expect(screen.getByText(`${roleName} 파트를 확인하는 중...`)).toBeTruthy();
+ expect(screen.getByRole("button", { name: "취소" })).toBeTruthy();
+
+ rerender( );
+ fireEvent.click(screen.getByRole("tab", { name: roleName }));
+ expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복");
+ });
+
+ it("keeps range-backed setup available when no exact first note exists", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: "C#2",
+ highestNote: "E3"
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+
+ const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i });
+ expect(setupButton).toBeEnabled();
+ const visibleLabel = setupButton.textContent?.trim() ?? "";
+ expect(visibleLabel).not.toBe("");
+ expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel);
+ });
+
+ it("keeps placeholder-looking role names literal in setup copy", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ name: "{low}",
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: "C#2",
+ highestNote: "E3"
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
+
+ expect(
+ screen.getByRole("button", {
+ name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map"
+ })
+ ).toBeEnabled();
+ });
+
+ it("keeps disabled stem controls discoverable by their visible labels", () => {
+ const song = createDemoRehearsalSong();
+
+ render( );
+
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+ expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem");
+ expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent(
+ "Solo / mute others"
+ );
+ });
+
+ it("natively disables setup when a cue has neither an entrance nor a playable range", () => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote: " ",
+ highestNote: " "
+ }
+ }));
+
+ render( );
+ fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
+
+ const setupButton = screen.getByRole("button", {
+ name: "No first entrance or playable range yet. Stay on tonight's map."
+ });
+ expect(setupButton).toBeDisabled();
+ });
+
+ it.each([
+ ["none", "none"],
+ ["E3", "C#2"],
+ ["low", "high"]
+ ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => {
+ const song = createDemoRehearsalSong();
+ const roleId = song.sections[0]!.roles[0]!.id;
+ replaceRole(song, roleId, (role) => ({
+ ...role,
+ setupNote: "Tune down a whole step.",
+ transcription: undefined,
+ range: {
+ ...role.range,
+ lowestNote,
+ highestNote
+ }
+ }));
+
+ render( );
+ 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();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..38e439216 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 · 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();
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
@@ -115,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();
@@ -326,4 +331,81 @@ 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: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/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: "Play stem. 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).toBeDisabled();
+ 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 7a874e771..3ee2c352e 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,8 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
-import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale } from "../../i18n";
+import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -41,6 +41,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi
}
type Translator = ReturnType;
+type TranscriptionNote = NonNullable[number];
/** Documented. */
function preventUnavailableAction(event: MouseEvent): void {
@@ -58,6 +59,47 @@ 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();
+}
+
+/** 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) {
@@ -121,6 +163,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
@@ -150,7 +193,33 @@ 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 activeRoleRange = playableRange(
+ activeRoleDetails?.range.lowestNote,
+ activeRoleDetails?.range.highestNote
+ );
+ const roleRangeLow = activeRoleRange?.lowestNote;
+ const roleRangeHigh = activeRoleRange?.highestNote;
+ const setupCue = roleSetupCue(activeRoleDetails);
+ const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
+ const hasPlayableRange = activeRoleRange !== null;
+ const hasStartEvidence = Boolean(firstNote || hasPlayableRange);
+ const canArmTonightSetup = Boolean(setupCue && hasStartEvidence);
const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]);
const firstRangeCopy = firstRange
? fillRangeCopy(
@@ -225,6 +294,66 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
+ const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
+ const setupUnavailableLabel = setupCue && !hasStartEvidence
+ ? t("workspaceSetupStartUnavailable")
+ : t("workspaceSetupUnavailable");
+ const setupActionLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
+ : firstNote
+ ? fillTranslation(t("workspaceSetupActionWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset)
+ })
+ : fillTranslation(t("workspaceSetupActionWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!
+ });
+ const setupAriaLabel = !canArmTonightSetup
+ ? setupUnavailableLabel
+ : firstNote
+ ? fillTranslation(t("workspaceSetupAriaWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupSentenceCue
+ })
+ : fillTranslation(t("workspaceSetupAriaWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
+ setup: setupSentenceCue
+ });
+ const setupStatus = firstNote
+ ? fillTranslation(t("workspaceSetupArmedWithNote"), {
+ role: roleName,
+ pitch: firstNote.pitch,
+ start: formatTimelineTime(firstNote.onset),
+ setup: setupSentenceCue
+ })
+ : fillTranslation(t("workspaceSetupArmedWithRange"), {
+ role: roleName,
+ low: roleRangeLow!,
+ high: roleRangeHigh!,
+ setup: setupSentenceCue
+ });
+
+ /** 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 = () => {
@@ -364,7 +493,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -376,8 +505,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- Loop section
+ {t("workspaceLoopAction")}
Solo / mute others
- {canTranscribeBass ? (
+ {canArmTonightSetup ? (
- Transcribe Bass
+ {setupActionLabel}
) : (
- Transcribe Bass
+ {setupUnavailableLabel}
)}
@@ -438,14 +569,25 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
-
+
{t("workspaceTranspositionLabel")}
- {roleTranspositionPlan}
+ {setupCue ?? roleTranspositionPlan}
+ {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? (
+
{roleTranspositionPlan}
+ ) : null}
{song.collaboration && (
@@ -498,7 +640,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
-
+ {armedSetupRoleId === activeRole ? (
+
+ {setupStatus}
+
+ ) : null}
+
)}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..a0a1369c1 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { createTranslator, detectPreferredLocale } from "./index";
+import { createTranslator, detectPreferredLocale, fillTranslation } from "./index";
import koCommon from "../locales/ko/common.json";
describe("i18n", () => {
@@ -75,4 +75,14 @@ describe("i18n", () => {
}
});
});
+
+ describe("fillTranslation", () => {
+ it("keeps unknown placeholders and placeholder-shaped values literal", () => {
+ expect(
+ fillTranslation("Set up {role} in {range}", {
+ role: "{range}"
+ })
+ ).toBe("Set up {range} in {range}");
+ });
+ });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..0dcf9ad30 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -6,6 +6,8 @@ export type Locale = "en" | "ko";
/** Documented. */
export type TranslationKey = keyof typeof enCommon;
+const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
+
const dictionaries = {
en: enCommon,
ko: koCommon
@@ -18,6 +20,16 @@ export function createTranslator(locale: Locale = "en") {
};
}
+/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */
+export function fillTranslation(
+ template: string,
+ values: Readonly>
+): string {
+ return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) =>
+ Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder
+ );
+}
+
/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..0c6f636f7 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -50,6 +50,27 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
+ "workspaceThisRole": "This part",
+ "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
+ "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
+ "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",
+ "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.",
+ "workspaceLoopAction": "Loop section",
+ "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.",
+ "grooveMapLoading": "Checking the {role} line...",
+ "grooveMapCancel": "Cancel",
+ "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.",
+ "grooveMapRegionLabel": "{role} transcription groove map",
+ "grooveMapComplete": "Transcription complete. {count} notes analyzed.",
+ "grooveMapMapped": "{count} notes mapped for rehearsal",
+ "grooveMapEntranceTitle": "Tonight's entrance · {note}",
+ "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}",
"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 0f6c6c66d..34a0147cd 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -50,6 +50,27 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
+ "workspaceThisRole": "이 파트",
+ "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
+ "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
+ "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요",
+ "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요",
+ "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
+ "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
+ "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
+ "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.",
+ "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
+ "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
+ "workspaceLoopAction": "구간 반복",
+ "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.",
+ "grooveMapLoading": "{role} 파트를 확인하는 중...",
+ "grooveMapCancel": "취소",
+ "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.",
+ "grooveMapRegionLabel": "{role} 채보 그루브 맵",
+ "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.",
+ "grooveMapMapped": "합주용 음표 {count}개 매핑됨",
+ "grooveMapEntranceTitle": "오늘의 진입점 · {note}",
+ "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..15aeafa92 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 required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, optional `entranceOnset`, and `onCancel` only when a real cancellation capability exists. Loading copy must not invent a progress percentage. 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()`. |
diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py
index ec6b39a32..370d556c7 100644
--- a/services/analysis-engine/tests/test_release_metadata.py
+++ b/services/analysis-engine/tests/test_release_metadata.py
@@ -62,6 +62,18 @@ 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")
+ release_marker = "## [0.1.3]"
+ assert release_marker in changelog
+ unreleased = changelog.split(release_marker, 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()
diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py
index 1d8224c5a..6a0853944 100644
--- a/services/analysis-engine/tests/test_supply_chain_policy.py
+++ b/services/analysis-engine/tests/test_supply_chain_policy.py
@@ -1275,9 +1275,7 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None:
workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8")
assert "concurrency:" in workflow, workflow_name
assert "cancel-in-progress: false" in workflow, workflow_name
- assert "contents: read" in workflow or "permissions: read-all" in workflow, (
- workflow_name
- )
+ assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name
assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")
From 0202845cb0d56e90b25a7c9e0c63ff44f4a04707 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 25 Sep 2026 05:30:35 +0000
Subject: [PATCH 6/6] perf: apply useCallback to handlePracticeProgressChange
---
.jules/bolt.md | 7 +-
AGENTS.md | 1 -
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 10 -
CLAUDE.md | 2 -
.../src/features/workspace/GrooveMap.test.tsx | 50 -----
.../src/features/workspace/GrooveMap.tsx | 76 ++-----
.../workspace/Workspace.review.test.tsx | 192 -----------------
.../src/features/workspace/Workspace.test.tsx | 94 +--------
.../src/features/workspace/Workspace.tsx | 196 ++----------------
apps/desktop/src/i18n/index.test.ts | 12 +-
apps/desktop/src/i18n/index.ts | 12 --
apps/desktop/src/locales/en/common.json | 21 --
apps/desktop/src/locales/ko/common.json | 21 --
docs/design-system/component-contract.md | 2 +-
.../tests/test_release_metadata.py | 12 --
.../tests/test_supply_chain_policy.py | 4 +-
17 files changed, 60 insertions(+), 655 deletions(-)
delete mode 100644 apps/desktop/src/features/workspace/GrooveMap.test.tsx
delete mode 100644 apps/desktop/src/features/workspace/Workspace.review.test.tsx
diff --git a/.jules/bolt.md b/.jules/bolt.md
index 2a88db5bc..1e5d1493e 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -62,6 +62,7 @@
**Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components.
**Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations.
-## 2026-09-05 - Min/max scans remain linear
-**Learning:** Replacing `Array.prototype.reduce()` with an indexed loop for an extremum search can remove callback dispatch, but both forms still scan every element and remain O(N). A simple `if (value > max)` is not automatically semantics-preserving: it ignores `NaN`, whereas `Math.max` propagates it. A microbenchmark on an arbitrary array does not establish buyer-path improvement.
-**Action:** Preserve the existing reduction semantics first, then profile representative GrooveMap transcription sizes in the actual Chromium/Electron render path before claiming material latency, heap, or GC gains.
+## 2024-09-24 - Missing useCallback on handlers breaks React.memo
+
+**Learning:** In React, passing unmemoized event handler functions (like `handlePracticeProgressChange`) to a `React.memo`-wrapped child component (`PracticeProgress`) completely breaks its memoization, causing it to re-render every time the parent (`Workspace`) renders, even if its props semantically didn't change.
+**Action:** Always wrap event handlers passed as props to memoized components in `useCallback` to ensure referential stability across parent renders.
diff --git a/AGENTS.md b/AGENTS.md
index 72e69fd45..b9a67ce17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,6 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
-- After a part is selected, the role strip must name tonight's setup from `setupNote` / transposition / simplification and point at the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op, and do not invent Stem Lab isolation here.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1aa737eed..ca0df5ac4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-08-18
+Last updated: 2026-03-11
## Brand source
@@ -68,7 +68,6 @@ Last updated: 2026-08-18
- 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 6f6b386b5..34331fb86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,6 @@
### Added
-- The ready workspace can set up tonight's selected part from the analyzed setup cue and name the first entrance on the groove map, instead of leaving `Transcribe Bass` inert.
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
@@ -16,16 +15,7 @@
### Fixed
-- Keep Groove Map loading truthfully indeterminate unless real progress exists, and expose Cancel only when a cancellation callback is actually available.
-- Keep disabled Stem Player controls discoverable by their visible labels for
- assistive technology and speech input while retaining the translated
- unavailable reason in each accessible name and tooltip.
-- Reject sentinel, malformed, and inverted setup ranges before they can enable
- a buyer-visible rehearsal action or render as playable evidence.
-- Localize Groove Map states and the unavailable Loop control through the
- owned translation boundary, with literal fail-closed placeholder handling.
- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
-- Keep the Groove Map role-aware for non-bass parts, preserve a visible keyboard focus indicator, emit only one first-entrance DOM anchor for simultaneous notes, and fail closed when setup lacks both an analyzed entrance and a playable range.
## [0.1.3] - 2026-04-29
diff --git a/CLAUDE.md b/CLAUDE.md
index 5b9d65151..b5a34c1fa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,8 +6,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.
-After a part is selected, the role-strip setup control must name tonight's setup cue and the earliest analyzed note, or a validated playable range when no exact note exists. Do not leave `Transcribe Bass` as a no-op.
-
Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.
## Common commands
diff --git a/apps/desktop/src/features/workspace/GrooveMap.test.tsx b/apps/desktop/src/features/workspace/GrooveMap.test.tsx
deleted file mode 100644
index 723f0aac8..000000000
--- a/apps/desktop/src/features/workspace/GrooveMap.test.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { GrooveMap, maximumNoteOffset } from "./GrooveMap";
-
-describe("maximumNoteOffset", () => {
- it("preserves the ten-second floor and finite maximum", () => {
- expect(maximumNoteOffset([])).toBe(10);
- expect(
- maximumNoteOffset([
- { onset: 0, offset: 1.5, pitch: "C4", velocity: 100 },
- { onset: 1.5, offset: 12, pitch: "D4", velocity: 100 }
- ])
- ).toBe(12);
- });
-
- it("preserves Math.max non-finite semantics until shared admission rejects them", () => {
- expect(
- Number.isNaN(
- maximumNoteOffset([
- { onset: 0, offset: Number.NaN, pitch: "C4", velocity: 100 },
- { onset: 1, offset: 12, pitch: "D4", velocity: 100 }
- ])
- )
- ).toBe(true);
- expect(
- maximumNoteOffset([
- { onset: 0, offset: Number.POSITIVE_INFINITY, pitch: "C4", velocity: 100 }
- ])
- ).toBe(Number.POSITIVE_INFINITY);
- });
-});
-
-describe("GrooveMap maximum-offset geometry", () => {
- it("uses an offset beyond the ten-second floor for rendered note geometry", () => {
- render(
-
- );
-
- expect(screen.getByTitle("D4 (10.00s - 20.00s)")).toHaveStyle({
- left: "50%",
- width: "50%"
- });
- });
-});
diff --git a/apps/desktop/src/features/workspace/GrooveMap.tsx b/apps/desktop/src/features/workspace/GrooveMap.tsx
index ccb92b3d8..2745d4d79 100644
--- a/apps/desktop/src/features/workspace/GrooveMap.tsx
+++ b/apps/desktop/src/features/workspace/GrooveMap.tsx
@@ -2,36 +2,23 @@ import { memo, useMemo } from "react";
import type { TranscriptionNote } from "@bandscope/shared-types";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
const EMPTY_NOTES: TranscriptionNote[] = [];
-/** Preserve the former reduction semantics while avoiding reducer callback dispatch. */
-function maximumNoteOffset(notes: readonly TranscriptionNote[]): number {
- let max = 10;
- for (let i = 0; i < notes.length; i++) {
- // Keep Math.max here: shared timing admission does not yet reject every non-finite value.
- // A simple `>` comparison would therefore silently change NaN handling at this UI boundary.
- max = Math.max(max, notes[i]!.offset);
- }
- return max;
-}
-
-/** Inputs for the selected role's rehearsal groove map. */
+/** Documented. */
interface GrooveMapProps {
notes?: TranscriptionNote[];
isLoading?: boolean;
- entranceOnset?: number;
- roleName: string;
- onCancel?: () => void;
}
-/** Render the selected role's transcription and optional first-entrance emphasis. */
-function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCancel }: GrooveMapProps) {
+/** Documented. */
+function GrooveMapComponent({ notes, isLoading }: GrooveMapProps) {
const renderedNotes = notes ?? EMPTY_NOTES;
- const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const maxTime = useMemo(() => maximumNoteOffset(renderedNotes), [renderedNotes]);
+ // Find max offset to determine timeline width
+ const maxTime = useMemo(() => {
+ return renderedNotes.reduce((max, n) => Math.max(max, n.offset), 10);
+ }, [renderedNotes]);
// Unique pitches to determine vertical lanes (avoiding 88-key piano roll)
const uniquePitches = useMemo(() => {
@@ -49,13 +36,6 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
return map;
}, [uniquePitches]);
- const entranceIndex = useMemo(() => {
- if (entranceOnset === undefined) {
- return -1;
- }
- return renderedNotes.findIndex((note) => note.onset === entranceOnset);
- }, [entranceOnset, renderedNotes]);
-
if (isLoading) {
return (
- {fillTranslation(t("grooveMapLoading"), { role: roleName })}
+ Checking the bass line... 45%
- {onCancel ? (
-
- {t("grooveMapCancel")}
-
- ) : null}
+
+ Cancel
+
);
}
if (renderedNotes.length === 0) {
return (
-
- {fillTranslation(t("grooveMapEmpty"), { role: roleName })}
+
+ No bass line transcription yet. Use it when you want to check the groove before rehearsal.
);
}
@@ -94,13 +68,13 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
className="relative mt-4 overflow-x-auto rounded-lg border border-cyan-200/15 bg-slate-950/80 p-4 shadow-inner shadow-cyan-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
role="region"
tabIndex={0}
- aria-label={fillTranslation(t("grooveMapRegionLabel"), { role: roleName })}
+ aria-label="Bass transcription groove map"
>
- {fillTranslation(t("grooveMapComplete"), { count: renderedNotes.length })}
+ Transcription complete. {renderedNotes.length} notes analyzed.
- {fillTranslation(t("grooveMapMapped"), { count: renderedNotes.length })}
+ {renderedNotes.length} notes mapped for rehearsal
@@ -121,26 +95,20 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
const leftPercent = (note.onset / maxTime) * 100;
const widthPercent = ((note.offset - note.onset) / maxTime) * 100;
const noteLabel = `${note.pitch} (${note.onset.toFixed(2)}s - ${note.offset.toFixed(2)}s)`;
- const isEntrance = entranceOnset !== undefined && note.onset === entranceOnset;
return (
- {isEntrance ? fillTranslation(t("grooveMapEntranceAnnouncement"), { note: noteLabel }) : noteLabel}
+ {noteLabel}
);
@@ -152,4 +120,4 @@ function GrooveMapComponent({ notes, isLoading, entranceOnset, roleName, onCance
const GrooveMap = memo(GrooveMapComponent);
-export { GrooveMap, maximumNoteOffset };
+export { GrooveMap };
diff --git a/apps/desktop/src/features/workspace/Workspace.review.test.tsx b/apps/desktop/src/features/workspace/Workspace.review.test.tsx
deleted file mode 100644
index f264dacb1..000000000
--- a/apps/desktop/src/features/workspace/Workspace.review.test.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import { readFileSync } from "node:fs";
-import { resolve } from "node:path";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { createDemoRehearsalSong } from "@bandscope/shared-types";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { GrooveMap } from "./GrooveMap";
-import { Workspace } from "./Workspace";
-
-/** Replace every copy of one rehearsal role so cross-section aggregation stays deterministic. */
-function replaceRole(song: ReturnType
, roleId: string, replace: (role: (typeof song.sections)[number]["roles"][number]) => (typeof song.sections)[number]["roles"][number]) {
- song.sections = song.sections.map((section) => ({
- ...section,
- roles: section.roles.map((role) => (role.id === roleId ? replace(role) : role))
- }));
-}
-
-describe("Workspace review regressions", () => {
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it("keeps copy interpolation free of dynamically constructed regular expressions", () => {
- const source = readFileSync(resolve(process.cwd(), "src/features/workspace/Workspace.tsx"), "utf8");
- expect(source).not.toContain("new RegExp(");
- });
-
- it("labels a non-bass groove map by role, keeps keyboard focus visible, and emits one entrance anchor", () => {
- render(
-
- );
-
- 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 without inventing progress", () => {
- 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...")).toBeTruthy();
- });
-
- it("does not expose a cancel action without cancellation capability and invokes it when provided", () => {
- const onCancel = vi.fn();
- const { rerender } = render( );
-
- expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
-
- rerender( );
- fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
-
- expect(onCancel).toHaveBeenCalledTimes(1);
- });
-
- it("localizes GrooveMap states and the unavailable loop control", () => {
- vi.stubGlobal("navigator", { language: "ko-KR" });
- const song = createDemoRehearsalSong();
- const roleName = song.sections[0]!.roles[0]!.name;
- const { rerender } = render( );
-
- expect(screen.getByText(`${roleName} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.`)).toBeTruthy();
-
- rerender( {}} />);
- expect(screen.getByText(`${roleName} 파트를 확인하는 중...`)).toBeTruthy();
- expect(screen.getByRole("button", { name: "취소" })).toBeTruthy();
-
- rerender( );
- fireEvent.click(screen.getByRole("tab", { name: roleName }));
- expect(screen.getByRole("button", { name: /구간 반복/ })).toHaveTextContent("구간 반복");
- });
-
- it("keeps range-backed setup available when no exact first note exists", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: "C#2",
- highestNote: "E3"
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
-
- const setupButton = screen.getByRole("button", { name: /then start in C#2–E3/i });
- expect(setupButton).toBeEnabled();
- const visibleLabel = setupButton.textContent?.trim() ?? "";
- expect(visibleLabel).not.toBe("");
- expect(setupButton.getAttribute("aria-label")).toContain(visibleLabel);
- });
-
- it("keeps placeholder-looking role names literal in setup copy", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- name: "{low}",
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: "C#2",
- highestNote: "E3"
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: "{low}" }));
-
- expect(
- screen.getByRole("button", {
- name: "Set up {low} · then start in C#2–E3. Setup: Tune down a whole step. Use tonight's map"
- })
- ).toBeEnabled();
- });
-
- it("keeps disabled stem controls discoverable by their visible labels", () => {
- const song = createDemoRehearsalSong();
-
- render( );
-
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
- expect(screen.getByRole("button", { name: /Play stem/ })).toHaveTextContent("Play stem");
- expect(screen.getByRole("button", { name: /Solo \/ mute others/ })).toHaveTextContent(
- "Solo / mute others"
- );
- });
-
- it("natively disables setup when a cue has neither an entrance nor a playable range", () => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote: " ",
- highestNote: " "
- }
- }));
-
- render( );
- fireEvent.click(screen.getByRole("tab", { name: song.sections[0]!.roles[0]!.name }));
-
- const setupButton = screen.getByRole("button", {
- name: "No first entrance or playable range yet. Stay on tonight's map."
- });
- expect(setupButton).toBeDisabled();
- });
-
- it.each([
- ["none", "none"],
- ["E3", "C#2"],
- ["low", "high"]
- ])("rejects malformed setup range %s–%s", (lowestNote, highestNote) => {
- const song = createDemoRehearsalSong();
- const roleId = song.sections[0]!.roles[0]!.id;
- replaceRole(song, roleId, (role) => ({
- ...role,
- setupNote: "Tune down a whole step.",
- transcription: undefined,
- range: {
- ...role.range,
- lowestNote,
- highestNote
- }
- }));
-
- render( );
- 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();
- });
-});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 38e439216..7837bf80e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -8,7 +8,6 @@ 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", {
@@ -29,7 +28,6 @@ describe("Workspace", () => {
configurable: true,
value: originalRevokeObjectUrl
});
- HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it("updates practice progress immutably through onSongUpdate", () => {
@@ -87,23 +85,20 @@ describe("Workspace", () => {
expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy();
});
- it("enables tonight's setup from the role setup cue rather than the role name", () => {
+ it("enables bass transcription from selected role metadata rather than role id text", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
id: "low-end",
- name: "Bass Guitar",
- setupNote: "Keep the attack short so the verse breathes."
+ name: "Bass Guitar"
};
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const setupButton = screen.getByRole("button", {
- 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();
+ const transcribeButton = screen.getByRole("button", { name: "Transcribe Bass" }) as HTMLButtonElement;
+ expect(transcribeButton.disabled).toBe(false);
+ expect(transcribeButton.title).toBe("Transcribe part");
});
it("renders bass transcription in the dark rehearsal cockpit system", () => {
@@ -120,7 +115,7 @@ describe("Workspace", () => {
render( );
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
- const grooveMap = screen.getByRole("region", { name: /bass guitar transcription groove map/i });
+ const grooveMap = screen.getByRole("region", { name: /bass transcription groove map/i });
expect(grooveMap.className).toContain("bg-slate-950");
expect(screen.getByText("E2")).toBeTruthy();
expect(screen.getByText("G2")).toBeTruthy();
@@ -331,81 +326,4 @@ 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: /Set up Bass Guitar · then start on A2 from 0:42\. Setup: Keep the attack short so the verse breathes/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: "Play stem. 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).toBeDisabled();
- 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 3ee2c352e..7a874e771 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -4,8 +4,8 @@ import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
-import { fillRangeCopy, firstRangeSqueeze, playableRange } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "../../i18n";
+import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -41,7 +41,6 @@ function downloadTextFile(contents: string, type: string, filename: string): voi
}
type Translator = ReturnType;
-type TranscriptionNote = NonNullable[number];
/** Documented. */
function preventUnavailableAction(event: MouseEvent): void {
@@ -59,47 +58,6 @@ 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();
-}
-
-/** 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) {
@@ -163,7 +121,6 @@ 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
@@ -193,33 +150,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
if (!activeRole) return undefined;
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
- 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 activeRoleRange = playableRange(
- activeRoleDetails?.range.lowestNote,
- activeRoleDetails?.range.highestNote
- );
- const roleRangeLow = activeRoleRange?.lowestNote;
- const roleRangeHigh = activeRoleRange?.highestNote;
- const setupCue = roleSetupCue(activeRoleDetails);
- const setupSentenceCue = setupCue ? sentenceFragment(setupCue) : "";
- const hasPlayableRange = activeRoleRange !== null;
- 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(
@@ -294,66 +225,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
- const roleName = nonBlankText(activeRoleDetails?.name) ?? t("workspaceThisRole");
- const setupUnavailableLabel = setupCue && !hasStartEvidence
- ? t("workspaceSetupStartUnavailable")
- : t("workspaceSetupUnavailable");
- const setupActionLabel = !canArmTonightSetup
- ? setupUnavailableLabel
- : firstNote
- ? fillTranslation(t("workspaceSetupActionWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset)
- })
- : fillTranslation(t("workspaceSetupActionWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!
- });
- const setupAriaLabel = !canArmTonightSetup
- ? setupUnavailableLabel
- : firstNote
- ? fillTranslation(t("workspaceSetupAriaWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset),
- setup: setupSentenceCue
- })
- : fillTranslation(t("workspaceSetupAriaWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!,
- setup: setupSentenceCue
- });
- const setupStatus = firstNote
- ? fillTranslation(t("workspaceSetupArmedWithNote"), {
- role: roleName,
- pitch: firstNote.pitch,
- start: formatTimelineTime(firstNote.onset),
- setup: setupSentenceCue
- })
- : fillTranslation(t("workspaceSetupArmedWithRange"), {
- role: roleName,
- low: roleRangeLow!,
- high: roleRangeHigh!,
- setup: setupSentenceCue
- });
-
- /** 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 = () => {
@@ -493,7 +364,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
@@ -505,8 +376,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- {t("workspaceLoopAction")}
+ Loop section
Solo / mute others
- {canArmTonightSetup ? (
+ {canTranscribeBass ? (
- {setupActionLabel}
+ Transcribe Bass
) : (
- {setupUnavailableLabel}
+ Transcribe Bass
)}
@@ -569,25 +438,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
-
+
{t("workspaceTranspositionLabel")}
- {setupCue ?? roleTranspositionPlan}
+ {roleTranspositionPlan}
- {roleTranspositionPlan && roleTranspositionPlan !== setupCue ? (
-
{roleTranspositionPlan}
- ) : null}
{song.collaboration && (
@@ -640,17 +498,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
- {armedSetupRoleId === activeRole ? (
-
- {setupStatus}
-
- ) : null}
-
+
)}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index a0a1369c1..dc49a0a25 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { createTranslator, detectPreferredLocale, fillTranslation } from "./index";
+import { createTranslator, detectPreferredLocale } from "./index";
import koCommon from "../locales/ko/common.json";
describe("i18n", () => {
@@ -75,14 +75,4 @@ describe("i18n", () => {
}
});
});
-
- describe("fillTranslation", () => {
- it("keeps unknown placeholders and placeholder-shaped values literal", () => {
- expect(
- fillTranslation("Set up {role} in {range}", {
- role: "{range}"
- })
- ).toBe("Set up {range} in {range}");
- });
- });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 0dcf9ad30..1a9f471f0 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -6,8 +6,6 @@ export type Locale = "en" | "ko";
/** Documented. */
export type TranslationKey = keyof typeof enCommon;
-const TRANSLATION_PLACEHOLDER_PATTERN = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
-
const dictionaries = {
en: enCommon,
ko: koCommon
@@ -20,16 +18,6 @@ export function createTranslator(locale: Locale = "en") {
};
}
-/** Interpolate owned translation placeholders once while preserving missing and placeholder-shaped values literally. */
-export function fillTranslation(
- template: string,
- values: Readonly>
-): string {
- return template.replace(TRANSLATION_PLACEHOLDER_PATTERN, (placeholder, key: string) =>
- Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : placeholder
- );
-}
-
/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index 0c6f636f7..d803a765e 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -50,27 +50,6 @@
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
- "workspaceThisRole": "This part",
- "workspaceSetupActionWithNote": "Set up {role} · then start on {pitch} from {start}",
- "workspaceSetupActionWithRange": "Set up {role} · then start in {low}–{high}",
- "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",
- "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.",
- "workspaceLoopAction": "Loop section",
- "workspaceLoopUnavailable": "Loop section. Looping is not ready. Set up tonight's part first.",
- "grooveMapLoading": "Checking the {role} line...",
- "grooveMapCancel": "Cancel",
- "grooveMapEmpty": "No {role} transcription yet. Use it when you want to check the groove before rehearsal.",
- "grooveMapRegionLabel": "{role} transcription groove map",
- "grooveMapComplete": "Transcription complete. {count} notes analyzed.",
- "grooveMapMapped": "{count} notes mapped for rehearsal",
- "grooveMapEntranceTitle": "Tonight's entrance · {note}",
- "grooveMapEntranceAnnouncement": "Tonight's entrance. {note}",
"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 34a0147cd..0f6c6c66d 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -50,27 +50,6 @@
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
- "workspaceThisRole": "이 파트",
- "workspaceSetupActionWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작",
- "workspaceSetupActionWithRange": "{role} 세팅 · {low}–{high}에서 시작",
- "workspaceSetupAriaWithNote": "{role} 세팅 · {start}의 {pitch}부터 시작. 세팅: {setup}. 오늘 그루브 맵을 사용하세요",
- "workspaceSetupAriaWithRange": "{role} 세팅 · {low}–{high}에서 시작. 세팅: {setup}. 오늘 지도를 사용하세요",
- "workspaceSetupArmedWithNote": "오늘 {role} 세팅: {setup}. 그루브 맵에서 {start}의 {pitch}로 시작하세요.",
- "workspaceSetupArmedWithRange": "오늘 {role} 세팅: {setup}. 지도에서 {low}–{high}로 시작하세요.",
- "workspaceSetupUnavailable": "아직 세팅 큐가 없습니다. 오늘 지도에 머무르세요.",
- "workspaceSetupStartUnavailable": "첫 진입점이나 연주 가능 음역이 아직 없습니다. 오늘 지도를 확인하세요.",
- "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 먼저 오늘 파트를 세팅하세요.",
- "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 오늘 파트를 세팅하세요.",
- "workspaceLoopAction": "구간 반복",
- "workspaceLoopUnavailable": "구간 반복. 반복 재생은 아직 준비되지 않았습니다. 먼저 오늘 파트를 세팅하세요.",
- "grooveMapLoading": "{role} 파트를 확인하는 중...",
- "grooveMapCancel": "취소",
- "grooveMapEmpty": "{role} 채보가 아직 없습니다. 합주 전에 그루브를 확인할 때 사용하세요.",
- "grooveMapRegionLabel": "{role} 채보 그루브 맵",
- "grooveMapComplete": "채보가 완료되었습니다. 음표 {count}개를 분석했습니다.",
- "grooveMapMapped": "합주용 음표 {count}개 매핑됨",
- "grooveMapEntranceTitle": "오늘의 진입점 · {note}",
- "grooveMapEntranceAnnouncement": "오늘의 진입점. {note}",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
"sectionGrooveLabel": "그루브",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 15aeafa92..22602c313 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 required `roleName`, optional `notes?: TranscriptionNote[]`, `isLoading?: boolean`, optional `entranceOnset`, and `onCancel` only when a real cancellation capability exists. Loading copy must not invent a progress percentage. Preserve scrollable region semantics, focus-visible styling, 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[]` and `isLoading?: boolean`; 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()`. |
diff --git a/services/analysis-engine/tests/test_release_metadata.py b/services/analysis-engine/tests/test_release_metadata.py
index 370d556c7..ec6b39a32 100644
--- a/services/analysis-engine/tests/test_release_metadata.py
+++ b/services/analysis-engine/tests/test_release_metadata.py
@@ -62,18 +62,6 @@ 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")
- release_marker = "## [0.1.3]"
- assert release_marker in changelog
- unreleased = changelog.split(release_marker, 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()
diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py
index 6a0853944..1d8224c5a 100644
--- a/services/analysis-engine/tests/test_supply_chain_policy.py
+++ b/services/analysis-engine/tests/test_supply_chain_policy.py
@@ -1275,7 +1275,9 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None:
workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8")
assert "concurrency:" in workflow, workflow_name
assert "cancel-in-progress: false" in workflow, workflow_name
- assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name
+ assert "contents: read" in workflow or "permissions: read-all" in workflow, (
+ workflow_name
+ )
assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")