From f620ed613b935bde7eef61ccf549d13494afb60c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 23 Aug 2026 15:33:57 +0000 Subject: [PATCH 01/28] feat(workspace): name tonight's first ear check on the map Surface the earliest low or medium confidence section as a rehearsal next action so uncertain analysis is confirmed by ear before the room starts. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstEarCheckCallout.particle.test.tsx | 56 +++ ...rstEarCheckCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstEarCheckCallout.test.tsx | 237 +++++++++++++ .../workspace/FirstEarCheckCallout.tsx | 139 ++++++++ .../src/features/workspace/Workspace.test.tsx | 23 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstEarCheck.inherited-metadata.test.ts | 112 ++++++ .../features/workspace/firstEarCheck.test.ts | 246 +++++++++++++ .../src/features/workspace/firstEarCheck.ts | 333 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 ++- apps/desktop/src/i18n/index.ts | 36 +- apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-ear-check-navigation.md | 14 + 19 files changed, 1319 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstEarCheck.test.ts create mode 100644 apps/desktop/src/features/workspace/firstEarCheck.ts create mode 100644 docs/doctoring/reduced-motion-first-ear-check-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..3c33e6e82 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. +- Name tonight's first ear check with the uncertain part when an active role is corroborated, the owned confidence notes, the labeled section, and the time so the next action is obvious. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..5cc62434f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-23 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first ear check must name the uncertain part when corroborated, the owned confidence notes, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..dd875df1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first ear check in the mounted rehearsal workspace so the room can confirm uncertain analysis by ear before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..42981a700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first ear check and opens the matching rendered map section. Do not invent an ear check from groove, cue, setup, simplification, overlap, range copy, or high confidence. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx new file mode 100644 index 000000000..b31f72c4e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +describe("FirstEarCheckCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the ear-check action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + confidence: { + level: "medium", + source: "model", + notes: "Top voicing may need a quick ear check." + } + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:10 벌스에서 피아노 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 귀 확인 위치 열기" })); + + expect(screen.getByText("0:10에서 피아노 파트를 귀로 확인한 다음 합주를 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..2692668a1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.reduced-motion.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +describe("FirstEarCheckCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx new file mode 100644 index 000000000..6656c6595 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +function songWithEarCheck() { + return createDemoRehearsalSong(); +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstEarCheckCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("Nothing still needs an ear check. Stay on tonight's map until a part is marked uncertain.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithEarCheck(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same ear-check signature", () => { + const firstSong = songWithEarCheck(); + const nextSong = songWithEarCheck(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar still needs an ear check in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeNull(); + + grid.remove(); + }); + + it("names the first ear check as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Watch the slide into the turnaround.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar ear check at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + + expect(screen.getByText("Bass Guitar still needs an ear check in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithEarCheck(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the first ear check changes or returns later", () => { + const initialSong = songWithEarCheck(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + + const nextSong = songWithEarCheck(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Bass Guitar still needs an ear check in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable ear check guidance-only", () => { + const song = songWithEarCheck(); + song.sections[0]!.confidence = { + level: "high", + source: "model", + notes: "Ready to trust the form." + }; + for (const role of song.sections[0]!.roles) { + role.confidence = { + level: "high", + source: "user", + notes: "Confirmed in rehearsal notes." + }; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("Nothing still needs an ear check. Stay on tonight's map until a part is marked uncertain.") + ).toBeTruthy(); + }); + + it("names a section-wide ear check when no part carries it", () => { + const song = songWithEarCheck(); + for (const node of song.sections[0]!.partGraph) { + node.is_active = false; + } + render(); + expect(screen.getByRole("button", { name: "Open the first ear check at 0:10" })).toBeTruthy(); + expect(screen.getByText("The verse still needs an ear check at 0:10.")).toBeTruthy(); + }); + + it("localizes the ear-check form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithEarCheck(); + song.sections[0]!.roles[0]!.name = "베이스 기타"; + song.sections[0]!.roles[1]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned confidence notes as a text node instead of template syntax", () => { + const song = songWithEarCheck(); + song.sections[0]!.roles[0]!.confidence = { + level: "medium", + source: "model", + notes: "Check {role} at {at}" + }; + song.sections[0]!.roles[1]!.confidence = { + level: "high", + source: "model", + notes: "Ready" + }; + song.sections[0]!.roles[2]!.confidence = { + level: "high", + source: "user", + notes: "Confirmed" + }; + render(); + expect(screen.getByText("Check {role} at {at}")).toBeTruthy(); + expect(screen.queryByText("Check Bass Guitar at 0:10")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx new file mode 100644 index 000000000..88eee96c1 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatEarCheckTime, resolveFirstEarCheck } from "./firstEarCheck"; + +/** Props for the first ear-check rehearsal callout. */ +export interface FirstEarCheckCalloutProps { + song: RehearsalSong; +} + +type EarCheckCopyValues = Readonly>; + +type OpenedEarCheck = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate ear-check placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatEarCheckCopy(template: string, values: EarCheckCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof EarCheckCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredEarCheckScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first ear check and open the matching rendered map section. */ +export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const earCheck = resolveFirstEarCheck(song); + const earCheckSectionIndex = + earCheck && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(earCheck.section) + : -1; + const [openedEarCheck, setOpenedEarCheck] = useState(null); + + useEffect(() => { + setOpenedEarCheck(null); + }, [ + songIdentity, + earCheckSectionIndex, + earCheck?.section.id, + earCheck?.holdingRole?.id, + earCheck?.atSeconds + ]); + + if (!earCheck) { + return ( + + ); + } + + const opened = + openedEarCheck !== null && + openedEarCheck.songIdentity === songIdentity && + openedEarCheck.sectionId === earCheck.section.id && + openedEarCheck.sectionIndex === earCheckSectionIndex && + openedEarCheck.holdingRoleId === (earCheck.holdingRole?.id ?? null) && + openedEarCheck.atSeconds === earCheck.atSeconds; + const at = formatEarCheckTime(earCheck.atSeconds); + const copyValues: EarCheckCopyValues = { + role: earCheck.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, earCheck.section.label), + at + }; + const hasRole = earCheck.holdingRole !== null; + const actionLabel = formatEarCheckCopy( + t(hasRole ? "firstEarCheckOpenAction" : "firstEarCheckOpenActionBand"), + copyValues + ); + const body = formatEarCheckCopy(t(hasRole ? "firstEarCheckBody" : "firstEarCheckBodyBand"), copyValues); + const armed = formatEarCheckCopy(t(hasRole ? "firstEarCheckArmed" : "firstEarCheckArmedBand"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..2aa7cfd3c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,27 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first ear check as workspace navigation", () => { + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect(screen.getAllByText("Watch the slide into the turnaround.").length).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar ear check at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..039b565bc 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts new file mode 100644 index 000000000..9cf1cca5b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts @@ -0,0 +1,112 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstEarCheck } from "./firstEarCheck"; + +function songWithEarCheck() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "ear-check-own"; + section.confidence = { + level: "high", + source: "model", + notes: "Ready to trust the form." + }; + section.roles = [ + { + ...section.roles[0]!, + confidence: { + level: "medium", + source: "model", + notes: "Watch the slide into the turnaround." + } + } + ]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstEarCheck inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithEarCheck(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstEarCheck(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithEarCheck(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithEarCheck(); + Object.defineProperty(section.roles[0]!, "confidence", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile confidence getter"); + } + }); + + expect(() => resolveFirstEarCheck(song)).not.toThrow(); + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("does not treat own accessors as stable ear-check identity authority", () => { + const { song, section } = songWithEarCheck(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "ear-check-own"; + } + }); + + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("does not let inherited confidence establish the ear check", () => { + const { song, section } = songWithEarCheck(); + const inheritedRole = Object.create({ + confidence: { + level: "low", + source: "model", + notes: "Inherited ear check" + } + }) as typeof section.roles[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "bass-guitar" }, + name: { configurable: true, enumerable: true, value: "Bass Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + section.confidence = { + level: "high", + source: "model", + notes: "Ready to trust the form." + }; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithEarCheck(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("ear-check-own"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Watch the slide into the turnaround."); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithEarCheck(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.test.ts new file mode 100644 index 000000000..e958ce3a1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatEarCheckTime, resolveFirstEarCheck } from "./firstEarCheck"; + +function withEarCheckSection( + overrides: { + id?: string; + start?: number; + end?: number; + notes?: string; + level?: "low" | "medium" | "high"; + sectionLevel?: "low" | "medium" | "high"; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-ear-check"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + section.confidence = { + level: overrides.sectionLevel ?? "high", + source: "model", + notes: "Section-level notes should not invent a clash." + }; + const roleId = overrides.roleId ?? "bass-guitar"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: overrides.priority ?? "high", + overlapWarnings: [], + setupNote: "Keep the attack short so the verse breathes.", + simplification: "Stay on roots if the chorus entrance gets muddy.", + cue: { kind: "transition", value: "Hold through the pickup." }, + range: { lowestNote: "C#2", highestNote: "E3" }, + confidence: { + level: overrides.level ?? "medium", + source: "model", + notes: overrides.notes ?? "Watch the slide into the turnaround." + } + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstEarCheck", () => { + it("picks the demo song's earliest named ear check and the part that carries it", () => { + const resolved = resolveFirstEarCheck(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Watch the slide into the turnaround."); + expect(formatEarCheckTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatEarCheckTime(Number.NaN)).toBe("0:00"); + expect(formatEarCheckTime(-4)).toBe("0:00"); + }); + + it("does not invent an ear check from groove, cue, setup, simplification, overlap, or range copy", () => { + const song = withEarCheckSection({ level: "high", sectionLevel: "high", notes: " " }); + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.setupNote = "Keep the attack short so the verse breathes."; + song.sections[0]!.roles[0]!.simplification = "Stay on roots if the chorus entrance gets muddy."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup." }; + song.sections[0]!.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + song.sections[0]!.roles[0]!.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("still names an ear check when owned notes are empty", () => { + const resolved = resolveFirstEarCheck(withEarCheckSection({ notes: " " })); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.hint).toBe(""); + }); + + it("prefers the earlier of two named ear checks", () => { + const song = withEarCheckSection({ id: "verse-late", start: 40, end: 56, roleId: "keys-right" }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium", + confidence: { + level: "low", + source: "model", + notes: "Bass entrance is still a guess." + } + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.hint).toBe("Bass entrance is still a guess."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time ear-check ties with locale-independent id ordering", () => { + const song = withEarCheckSection({ id: "ä-ear-check", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-ear-check"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstEarCheck(song)?.section.id).toBe("z-ear-check"); + }); + + it("prefers a low-confidence role over a medium-confidence role in the same section", () => { + const song = withEarCheckSection({ roleId: "keys-right", roleName: "Keys", level: "medium" }); + const section = song.sections[0]!; + const lowRole = { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "low", + confidence: { + level: "low" as const, + source: "model" as const, + notes: "Bass still needs an ear check." + } + }; + section.roles = [section.roles[0]!, lowRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstEarCheck(song)?.holdingRole?.id).toBe("bass-guitar"); + expect(resolveFirstEarCheck(song)?.hint).toBe("Bass still needs an ear check."); + }); + + it("breaks equal-uncertainty role ties with locale-independent id ordering", () => { + const song = withEarCheckSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + confidence: { + level: "medium" as const, + source: "model" as const, + notes: "ASCII ear check" + } + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstEarCheck(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a section-wide ear check when no active ranked role carries it", () => { + const song = withEarCheckSection({ isActive: false, sectionLevel: "medium" }); + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Watch the slide into the turnaround."); + }); + + it("skips an ear check whose rehearsal window is unbounded", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips an ear check whose end precedes its start", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length ear-check window", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips an ear check whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstEarCheck( + withEarCheckSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstEarCheck(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withEarCheckSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("keeps the ear check section-wide when role identities are duplicated", () => { + const song = withEarCheckSection({ sectionLevel: "medium" }); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("bounds the ear-check hint to 180 Unicode code points", () => { + const song = withEarCheckSection({ notes: `${"a".repeat(200)}` }); + const resolved = resolveFirstEarCheck(song); + expect(resolved?.hint.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the hint boundary", () => { + const song = withEarCheckSection({ notes: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstEarCheck(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.ts b/apps/desktop/src/features/workspace/firstEarCheck.ts new file mode 100644 index 000000000..6cf640b01 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.ts @@ -0,0 +1,333 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const UNCERTAINTY_RANK = { low: 0, medium: 1 } as const; +const MAX_EAR_CHECK_CHARACTERS = 180; + +/** Tonight's first named ear check: the earliest uncertain labeled section and the part that carries it. */ +export type FirstEarCheck = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative ear-check time as m:ss for rehearsal copy. */ +export function formatEarCheckTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return the owned low/medium confidence level, or null when the field cannot be shown. */ +function ownedEarCheckLevel(record: object): "low" | "medium" | null { + if (!hasOwnData(record, "confidence")) { + return null; + } + const confidence = (record as { confidence?: unknown }).confidence; + if (!isRuntimeObject(confidence) || !hasOwnData(confidence, "level")) { + return null; + } + const level = (confidence as { level?: unknown }).level; + if (level === "low" || level === "medium") { + return level; + } + return null; +} + +/** Return bounded owned confidence notes, or an empty string when none can be shown. */ +function ownedEarCheckNotes(record: object): string { + if (!hasOwnData(record, "confidence")) { + return ""; + } + const confidence = (record as { confidence?: unknown }).confidence; + if (!isRuntimeObject(confidence) || !hasOwnData(confidence, "notes")) { + return ""; + } + const notes = (confidence as { notes?: unknown }).notes; + if (typeof notes !== "string") { + return ""; + } + return truncateCodePoints(notes.trim(), MAX_EAR_CHECK_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if ( + !isRuntimeObject(timeRange) || + !hasOwnData(timeRange, "start") || + !hasOwnData(timeRange, "end") + ) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the most uncertain ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const leftLevel = ownedEarCheckLevel(left); + const rightLevel = ownedEarCheckLevel(right); + const leftRank = leftLevel === null ? Number.POSITIVE_INFINITY : UNCERTAINTY_RANK[leftLevel]; + const rightRank = rightLevel === null ? Number.POSITIVE_INFINITY : UNCERTAINTY_RANK[rightLevel]; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return whether a section has owned low/medium confidence on itself or any role. */ +function sectionHasEarCheck(section: RehearsalSection): boolean { + if (ownedEarCheckLevel(section) !== null) { + return true; + } + if (!hasOwnData(section, "roles") || !isDenseRuntimeArray(section.roles)) { + return false; + } + return section.roles.some((role) => isRuntimeObject(role) && ownedEarCheckLevel(role) !== null); +} + +/** Return owned notes for the holding part, else any owned uncertain role, else the section. */ +function ownedEarCheckHint(section: RehearsalSection, holdingRole: RehearsalRole | null): string { + if (holdingRole) { + const fromHolder = ownedEarCheckNotes(holdingRole); + if (fromHolder.length > 0) { + return fromHolder; + } + } + if (hasOwnData(section, "roles") && isDenseRuntimeArray(section.roles)) { + for (const role of section.roles) { + if (!isRuntimeObject(role) || ownedEarCheckLevel(role) === null) { + continue; + } + const notes = ownedEarCheckNotes(role); + if (notes.length > 0) { + return notes; + } + } + } + if (ownedEarCheckLevel(section) !== null) { + return ownedEarCheckNotes(section); + } + return ""; +} + +/** Resolve an ear check after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstEarCheck(song: RehearsalSong): FirstEarCheck | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) && + sectionHasEarCheck(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = candidates[0]; + if (!section) { + return null; + } + + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedEarCheckLevel(role) !== null) + ); + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + hint: ownedEarCheckHint(section, holdingRole) + }; +} + +/** Return the first named ear check, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstEarCheck(song: RehearsalSong): FirstEarCheck | null { + try { + return resolveSafeFirstEarCheck(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..6c4f1c9c3 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, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported section form label for Korean rehearsal copy", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-ear-check next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstEarCheckOpenAction")).toBe("{at} {role} 귀 확인 위치 열기"); + expect(t("firstEarCheckBody")).toBe("{at} {section}에서 {role} 파트를 귀로 확인하세요."); + expect(t("firstEarCheckArmed")).toBe("{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** 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 39f716d50..53f9bb750 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstEarCheckLabel": "Tonight's first ear check", + "firstEarCheckOpenAction": "Open {role} ear check at {at}", + "firstEarCheckOpenActionBand": "Open the first ear check at {at}", + "firstEarCheckBody": "{role} still needs an ear check in the {section} at {at}.", + "firstEarCheckBodyBand": "The {section} still needs an ear check at {at}.", + "firstEarCheckArmed": "Confirm {role} by ear at {at} before the room starts.", + "firstEarCheckArmedBand": "Confirm the {section} by ear at {at} before the room starts.", + "firstEarCheckUnavailable": "Nothing still needs an ear check. Stay on tonight's map until a part is marked uncertain." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..e66f28cd0 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstEarCheckLabel": "오늘 첫 귀 확인", + "firstEarCheckOpenAction": "{at} {role} 귀 확인 위치 열기", + "firstEarCheckOpenActionBand": "{at} 첫 귀 확인 위치 열기", + "firstEarCheckBody": "{at} {section}에서 {role} 파트를 귀로 확인하세요.", + "firstEarCheckBodyBand": "{at} {section}에서 귀로 한 번 더 확인하세요.", + "firstEarCheckArmed": "{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요.", + "firstEarCheckArmedBand": "{at}에서 귀로 확인한 다음 합주를 시작하세요.", + "firstEarCheckUnavailable": "아직 귀로 확인할 구간이 없습니다. 확신이 낮은 파트가 표시될 때까지 오늘 지도에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..ce9ba6ef2 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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. | +| First Ear Check Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx` | Name the uncertain part when an active graph node corroborates it, the owned `confidence.notes` text, the labeled section start, and the time. Do not invent an ear check from `groove`, cue text, `setupNote`, `simplification`, overlap warnings, range copy, or `high` confidence. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-overlap, first-groove, first-simplification, first-range, and first-form-label work. | | 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/docs/doctoring/reduced-motion-first-ear-check-navigation.md b/docs/doctoring/reduced-motion-first-ear-check-navigation.md new file mode 100644 index 000000000..3df1ecb95 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-ear-check-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-ear-check navigation + +Workspace map navigation for tonight's first ear check follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstEarCheckCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Ear-check resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, confidence marker, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: ear-check resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned confidence-notes string is rendered as a text node and is never rescanned as template syntax. Groove, cue, setup, simplification, overlap, and range copy cannot invent an ear check. High confidence is not an ear check. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the ear-check hint is bounded to 180 Unicode code points. +- Test points: inherited song/section/timing/role/graph/confidence metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From 9b89b6168b2c34f56efe9bb9cdbc8523a6d94be5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:45:07 -0700 Subject: [PATCH 02/28] test(workspace): cover ear-check ownership stability --- .../workspace/FirstEarCheckCallout.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx index 6656c6595..d43d05c50 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx @@ -79,6 +79,41 @@ describe("FirstEarCheckCallout", () => { grid.remove(); }); + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithEarCheck(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar ear check at 0:10" })); + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText(/Confirm Bass Guitar by ear at 0:10 before the room starts./)).toBeTruthy(); + expect(screen.queryByText("Bass Guitar still needs an ear check in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another uncertain part's notes under the named holding part", () => { + const song = songWithEarCheck(); + song.sections[0]!.roles[0]!.confidence = { + level: "low", + source: "model", + notes: "" + }; + song.sections[0]!.roles[1]!.confidence = { + level: "medium", + source: "model", + notes: "Check the keyboard voicing instead." + }; + + render(); + + expect(screen.getByText("Bass Guitar still needs an ear check in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText("Check the keyboard voicing instead.")).toBeNull(); + }); + it("names the first ear check as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); From bd9e9e3e1fc8d5fa0d3913cc436321fa68cf3c49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:45:54 -0700 Subject: [PATCH 03/28] fix(workspace): keep ear-check notes with named part --- apps/desktop/src/features/workspace/firstEarCheck.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstEarCheck.ts b/apps/desktop/src/features/workspace/firstEarCheck.ts index 6cf640b01..c9c14768f 100644 --- a/apps/desktop/src/features/workspace/firstEarCheck.ts +++ b/apps/desktop/src/features/workspace/firstEarCheck.ts @@ -255,13 +255,10 @@ function sectionHasEarCheck(section: RehearsalSection): boolean { return section.roles.some((role) => isRuntimeObject(role) && ownedEarCheckLevel(role) !== null); } -/** Return owned notes for the holding part, else any owned uncertain role, else the section. */ +/** Return notes owned by the named holding part, else any owned uncertain role, else the section. */ function ownedEarCheckHint(section: RehearsalSection, holdingRole: RehearsalRole | null): string { if (holdingRole) { - const fromHolder = ownedEarCheckNotes(holdingRole); - if (fromHolder.length > 0) { - return fromHolder; - } + return ownedEarCheckNotes(holdingRole); } if (hasOwnData(section, "roles") && isDenseRuntimeArray(section.roles)) { for (const role of section.roles) { From 327e5db3a8809587783a2edc44df9fa53c213fb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:46:26 -0700 Subject: [PATCH 04/28] fix(workspace): preserve ear-check state for stable songs --- .../workspace/FirstEarCheckCallout.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx index 88eee96c1..d43de67c0 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx @@ -23,6 +23,25 @@ type OpenedEarCheck = Readonly<{ atSeconds: number; }>; +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableEarCheckSongIdentity(song: RehearsalSong): unknown { + if (song === null || typeof song !== "object" || Array.isArray(song)) { + return song; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(song, "id"); + } catch { + return song; + } + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ? descriptor.value + : song; +} + /** Interpolate ear-check placeholders once so rehearsal data is never rescanned as template syntax. */ function formatEarCheckCopy(template: string, values: EarCheckCopyValues): string { return template.replace(/\{(role|section|at)\}/g, (placeholder) => { @@ -43,7 +62,7 @@ function preferredEarCheckScrollBehavior(): ScrollBehavior { export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); - const songIdentity: unknown = song; + const songIdentity = stableEarCheckSongIdentity(song); const runtimeSong = song as unknown as Partial | null; const earCheck = resolveFirstEarCheck(song); const earCheckSectionIndex = From 394001fd31f3c069859e8bf68d65b034cb10056f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:18:23 -0700 Subject: [PATCH 05/28] test(workspace): scope ear-check navigation --- ...stEarCheckCallout.workspace-scope.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..f123c6c58 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +describe("FirstEarCheckCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Bass Guitar ear check at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); From c0f0fecc50493f683f2d41785f50acb5a85b28c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:18:45 -0700 Subject: [PATCH 06/28] fix(workspace): scope ear-check navigation --- .../workspace/FirstEarCheckCallout.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx index d43de67c0..76bac5d03 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx @@ -58,6 +58,22 @@ function preferredEarCheckScrollBehavior(): ScrollBehavior { : "smooth"; } +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveEarCheckRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + /** Name tonight's first ear check and open the matching rendered map section. */ export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { const locale = detectPreferredLocale(); @@ -127,8 +143,8 @@ export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { ); -} +} \ No newline at end of file From db57800fd4609747c219f1c9a7cb1dec3810e4ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:24:47 -0700 Subject: [PATCH 20/28] test(workspace): align Korean ear-check tone --- .../features/workspace/FirstEarCheckCallout.particle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx index 4f063c555..572cf8ad6 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.particle.test.tsx @@ -42,7 +42,7 @@ describe("FirstEarCheckCallout Korean role copy", () => { render(); - expect(screen.getByText("0:10 벌스에서 피아노 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 피아노 파트는 아직 귀 확인이 필요합니다.")).toBeTruthy(); expect(screen.queryByText(/피아노이/)).toBeNull(); expect(screen.queryByText(/피아노가/)).toBeNull(); From df35aedf29d97abf0a831134e7f6eb7324a0a606 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:25:32 -0700 Subject: [PATCH 21/28] fix(workspace): align Korean ear-check status tone --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index ac90c09bf..e39cbedea 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -152,7 +152,7 @@ "firstEarCheckLabel": "오늘 첫 귀 확인", "firstEarCheckOpenAction": "{at} {role} 귀 확인 위치 열기", "firstEarCheckOpenActionBand": "{at} 첫 귀 확인 위치 열기", - "firstEarCheckBody": "{at} {section}에서 {role} 파트를 귀로 확인하세요.", + "firstEarCheckBody": "{at} {section}에서 {role} 파트는 아직 귀 확인이 필요합니다.", "firstEarCheckBodyBand": "{at} {section}에서 아직 귀 확인이 필요합니다.", "firstEarCheckArmed": "{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요.", "firstEarCheckArmedBand": "{at} {section}에서 귀로 확인한 다음 합주를 시작하세요.", From cb86ea270002bc17dc3503e04b113c55975a774b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:45:53 -0700 Subject: [PATCH 22/28] test(workspace): align Korean ear-check copy contract --- apps/desktop/src/i18n/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index 6c4f1c9c3..294fdb7c4 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -118,7 +118,7 @@ describe("i18n", () => { it("keeps Korean first-ear-check next-action copy particle-safe", () => { const t = createTranslator("ko"); expect(t("firstEarCheckOpenAction")).toBe("{at} {role} 귀 확인 위치 열기"); - expect(t("firstEarCheckBody")).toBe("{at} {section}에서 {role} 파트를 귀로 확인하세요."); + expect(t("firstEarCheckBody")).toBe("{at} {section}에서 {role} 파트는 아직 귀 확인이 필요합니다."); expect(t("firstEarCheckArmed")).toBe("{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요."); }); }); From 9e7bbea6b91abad79f854daa16dcb510e0068aab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 08:00:50 -0700 Subject: [PATCH 23/28] test(workspace): reject hostile ear-check sections access --- ...EarCheckCallout.sections-accessor.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.sections-accessor.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.sections-accessor.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.sections-accessor.test.tsx new file mode 100644 index 000000000..9c6f7bd87 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.sections-accessor.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +it("contains a hostile song sections accessor instead of crashing the callout", () => { + const song = createDemoRehearsalSong(); + Object.defineProperty(song, "sections", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song sections getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText( + "Nothing still needs an ear check. Stay on tonight's map until a part is marked uncertain." + ) + ).toBeTruthy(); +}); From 685e701a597c02e6ff2ef0787b5fe1eea6e449c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:38:25 -0700 Subject: [PATCH 24/28] test(workspace): align Korean ear-check expectations --- .../src/features/workspace/FirstEarCheckCallout.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx index 825f42819..2277ac3f8 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.test.tsx @@ -464,7 +464,7 @@ describe("FirstEarCheckCallout", () => { render(); - expect(screen.getByText("0:10 벌스에서 베이스 기타 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트는 아직 귀 확인이 필요합니다.")).toBeTruthy(); expect(screen.queryByText(/verse에서/)).toBeNull(); }); @@ -497,12 +497,12 @@ describe("FirstEarCheckCallout", () => { song.sections[0]!.roles[1]!.rehearsalPriority = "low"; song.sections[0]!.roles[2]!.rehearsalPriority = "low"; const { rerender } = render(); - expect(screen.getByText("0:10 벌스에서 베이스 기타 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트는 아직 귀 확인이 필요합니다.")).toBeTruthy(); vi.stubGlobal("navigator", { language: "en-US" }); rerender(); - expect(screen.getByText("0:10 벌스에서 베이스 기타 파트를 귀로 확인하세요.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트는 아직 귀 확인이 필요합니다.")).toBeTruthy(); expect(screen.queryByText("베이스 기타 still needs an ear check in the verse at 0:10.")).toBeNull(); }); From aa610108d76e1604ffe1ffffcdad99ed4e1323b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:12:09 -0700 Subject: [PATCH 25/28] test(accessibility): reject duplicate ear-check landmark ids --- .../FirstEarCheckCallout.concurrent-id.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstEarCheckCallout.concurrent-id.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.concurrent-id.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.concurrent-id.test.tsx new file mode 100644 index 000000000..ca4376a58 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.concurrent-id.test.tsx @@ -0,0 +1,17 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +it("keeps concurrent ear-check landmarks free of duplicate ids", () => { + const song = createDemoRehearsalSong(); + const { container } = render( + <> + + + + ); + + const ids = Array.from(container.querySelectorAll("[id]"), (element) => element.id); + expect(ids).toEqual(Array.from(new Set(ids))); +}); From da9c1a70041073421b798ca795f0239540e5681e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:13:24 -0700 Subject: [PATCH 26/28] fix(accessibility): keep ear-check landmark ids unique --- .../src/features/workspace/FirstEarCheckCallout.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx index dfdd35613..0a4c65e16 100644 --- a/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -183,6 +183,7 @@ export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { // Match the surrounding workspace pattern: locale detection and translation are mount-scoped. const [locale] = useState(() => detectPreferredLocale()); const t = useMemo(() => createTranslator(locale), [locale]); + const landmarkId = useId(); const resolution = useMemo(() => { const songIdentity = stableEarCheckSongIdentity(song); const runtimeSong = song as unknown as Partial | null; @@ -209,7 +210,7 @@ export function FirstEarCheckCallout({ song }: FirstEarCheckCalloutProps) { if (!earCheck) { return (