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 (
- {onCancel ? ( - - ) : null} +
); } 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 55bf0163d..7a874e771 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,11 +1,11 @@ -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"; 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( @@ -234,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 @@ -258,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] @@ -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 - {canArmTonightSetup ? ( + {canTranscribeBass ? ( ) : ( )}
@@ -569,25 +438,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {roleHarmonicExplanation}

-
+

- {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")