From 5c4115a8b74aeebfc1dbc9eb9762d8bde7b0fad1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 09:14:47 +0000 Subject: [PATCH 1/8] feat(workspace): name tonight's first simpler take on the map Name the owning part, labeled section, and time for the first owned simplification hint so the room can take the easier pass together. Open scrolls the renderer-owned song-structure section. Setup notes, cues, and overlap warnings cannot invent a simpler take. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- ...rstSimplificationCallout.particle.test.tsx | 41 +++ ...plificationCallout.reduced-motion.test.tsx | 42 +++ .../FirstSimplificationCallout.test.tsx | 192 +++++++++++++ .../workspace/FirstSimplificationCallout.tsx | 131 +++++++++ .../src/features/workspace/Workspace.test.tsx | 27 ++ .../src/features/workspace/Workspace.tsx | 11 +- ...tSimplification.inherited-metadata.test.ts | 96 +++++++ .../workspace/firstSimplification.test.ts | 171 ++++++++++++ .../features/workspace/firstSimplification.ts | 262 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 +++- apps/desktop/src/i18n/index.ts | 36 ++- apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- apps/desktop/vite.config.ts | 4 +- docs/design-system/component-contract.md | 1 + ...-motion-first-simplification-navigation.md | 14 + 20 files changed, 1088 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstSimplificationCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstSimplification.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSimplification.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSimplification.ts create mode 100644 docs/doctoring/reduced-motion-first-simplification-navigation.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..3c4401f22 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 simpler take with the owning part, the labeled section, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..a54eeaf9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- The mounted workspace copy for tonight's first simpler take must name the owning part, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a simpler take from setup notes, cues, overlap warnings, or unlabeled fields. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..a07336a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. +- Name tonight's first simpler take in the mounted rehearsal workspace so the room can get through the section together; 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. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..3c584744f 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). The ready workspace names tonight's first playable range and the next instrument check. `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 ready workspace names tonight's first playable range, the next instrument check, and tonight's first simpler take, then opens the matching rendered map section. Do not invent an easier pass from setup notes, cues, or overlap warnings. `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/FirstSimplificationCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.particle.test.tsx new file mode 100644 index 000000000..d18c53248 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.particle.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSimplificationCallout } from "./FirstSimplificationCallout"; + +describe("FirstSimplificationCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the simpler-take action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "피아노"; + + 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(); + + 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/FirstSimplificationCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..c47794389 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSimplificationCallout } from "./FirstSimplificationCallout"; + +describe("FirstSimplificationCallout 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.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 simpler take at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx new file mode 100644 index 000000000..76d718086 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx @@ -0,0 +1,192 @@ +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 { FirstSimplificationCallout } from "./FirstSimplificationCallout"; + +function songWithoutSimplification() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.simplification = " "; + } + } + return song; +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + const grid = document.createElement("div"); + 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("FirstSimplificationCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No simpler take yet. Stay on tonight's map until a part names an easier pass.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = createDemoRehearsalSong(); + 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 simpler take at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same simplification signature", () => { + const firstSong = createDemoRehearsalSong(); + const nextSong = createDemoRehearsalSong(); + 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 simpler take at 0:10" })); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar can play simpler in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeNull(); + + grid.remove(); + }); + + it("names the first simpler take as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Stay on roots if the chorus entrance gets muddy.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar simpler take at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).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 simpler take at 0:10" })); + + expect(screen.getByText("Bass Guitar can play simpler in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar simpler take 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 simpler take 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 simpler take changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar simpler take at 0:10" })); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + + const nextSong = createDemoRehearsalSong(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 24, end: 44 }; + rerender(); + expect(screen.getByText("Bass Guitar can play simpler in the verse at 0:24.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable simpler take guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No simpler take yet. Stay on tonight's map until a part names an easier pass.") + ).toBeTruthy(); + }); + + it("localizes the section form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "베이스"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 파트가 더 쉽게 칠 수 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx new file mode 100644 index 000000000..5df22b884 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx @@ -0,0 +1,131 @@ +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 { formatSimplificationTime, resolveFirstSimplification } from "./firstSimplification"; + +/** Props for the first-simplification rehearsal callout. */ +export interface FirstSimplificationCalloutProps { + song: RehearsalSong; +} + +type SimplificationCopyValues = Readonly>; + +type OpenedSimplification = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate simplification placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatSimplificationCopy(template: string, values: SimplificationCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof SimplificationCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredSimplificationScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first simpler take and open the matching rendered map section. */ +export function FirstSimplificationCallout({ song }: FirstSimplificationCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const simpler = resolveFirstSimplification(song); + const sectionIndex = + simpler && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(simpler.section) + : -1; + const [openedSimplification, setOpenedSimplification] = useState(null); + + useEffect(() => { + setOpenedSimplification(null); + }, [songIdentity, sectionIndex, simpler?.section.id, simpler?.holdingRole?.id, simpler?.atSeconds]); + + if (!simpler) { + return ( + + ); + } + + const opened = + openedSimplification !== null && + openedSimplification.songIdentity === songIdentity && + openedSimplification.sectionId === simpler.section.id && + openedSimplification.sectionIndex === sectionIndex && + openedSimplification.holdingRoleId === (simpler.holdingRole?.id ?? null) && + openedSimplification.atSeconds === simpler.atSeconds; + const at = formatSimplificationTime(simpler.atSeconds); + const copyValues: SimplificationCopyValues = { + role: simpler.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, simpler.section.label), + at + }; + const actionLabel = formatSimplificationCopy(t("firstSimplificationOpenAction"), copyValues); + const body = formatSimplificationCopy(t("firstSimplificationBody"), copyValues); + const armed = formatSimplificationCopy(t("firstSimplificationArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..c66c4799f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,31 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first simpler take 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 + }); + + const action = screen.getByRole("button", { + name: "Open Bass Guitar simpler take at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + expect(document.getElementById("workspace-surface-simplification")?.textContent).toContain( + "Stay on roots if the chorus entrance gets muddy." + ); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..d0c1f7b0f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { FirstSimplificationCallout } from "./FirstSimplificationCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,8 +92,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)}

@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstSimplification.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstSimplification.inherited-metadata.test.ts new file mode 100644 index 000000000..43bda6979 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSimplification.inherited-metadata.test.ts @@ -0,0 +1,96 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSimplification } from "./firstSimplification"; + +function songWithSimplification() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "verse-own"; + section.label = "verse"; + section.timeRange = { start: 10, end: 30 }; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + simplification: "Stay on roots if the chorus entrance gets muddy." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstSimplification inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithSimplification(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstSimplification(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithSimplification(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithSimplification(); + Object.defineProperty(section, "label", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile section label getter"); + } + }); + + expect(() => resolveFirstSimplification(song)).not.toThrow(); + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("does not treat own accessors as stable section identity authority", () => { + const { song, section } = songWithSimplification(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "verse-own"; + } + }); + + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("does not let inherited role, graph, or simplification metadata establish the easier pass", () => { + const { song, section } = songWithSimplification(); + const role = section.roles[0]!; + const node = section.partGraph[0]!; + const inheritedRole = Object.create(role) as typeof role; + section.roles = [inheritedRole]; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("rejects an inherited simplification string even when identity is owned", () => { + const { song, section } = songWithSimplification(); + const role = section.roles[0]!; + const inheritedHint = Object.create({ simplification: role.simplification }) as typeof role; + Object.assign(inheritedHint, { ...role }); + delete (inheritedHint as { simplification?: string }).simplification; + Object.setPrototypeOf(inheritedHint, { simplification: "Stay on roots if the chorus entrance gets muddy." }); + section.roles = [inheritedHint]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithSimplification(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSimplification.test.ts b/apps/desktop/src/features/workspace/firstSimplification.test.ts new file mode 100644 index 000000000..03efe1488 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSimplification.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatSimplificationTime, resolveFirstSimplification } from "./firstSimplification"; + +function withSimplification( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + simplification?: string; + setupNote?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-simple"; + section.label = "verse"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "bass-guitar"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: overrides.priority ?? "high", + simplification: overrides.simplification ?? "Stay on roots if the chorus entrance gets muddy.", + setupNote: overrides.setupNote ?? "Keep the attack short so the verse breathes." + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstSimplification", () => { + it("picks the demo song's earliest high-priority simpler take", () => { + const resolved = resolveFirstSimplification(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Stay on roots if the chorus entrance gets muddy."); + expect(formatSimplificationTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatSimplificationTime(Number.NaN)).toBe("0:00"); + expect(formatSimplificationTime(-4)).toBe("0:00"); + }); + + it("does not invent a simpler take from setup notes, cues, or overlap warnings", () => { + const song = withSimplification({ simplification: " " }); + song.sections[0]!.roles[0]!.setupNote = "Keep the attack short so the verse breathes."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup." }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Density warning: competing with keys."]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("prefers the earlier of two named simpler takes", () => { + const song = withSimplification({ id: "verse-late", start: 40, end: 56, roleId: "keys-right" }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.timeRange = { start: 10, end: 26 }; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium", + simplification: "Stay on roots." + } + ]; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstSimplification(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(10); + }); + + it("breaks same-time section ties with locale-independent id ordering", () => { + const song = withSimplification({ id: "ä-verse", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-verse"; + song.sections = [song.sections[0]!, ascii]; + expect(resolveFirstSimplification(song)?.section.id).toBe("z-verse"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withSimplification({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + simplification: "Drop the top extension." + }; + 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(resolveFirstSimplification(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("skips inactive parts even when they name a simpler take", () => { + expect(resolveFirstSimplification(withSimplification({ isActive: false }))).toBeNull(); + }); + + it("skips a simpler take whose rehearsal window is unbounded", () => { + expect(resolveFirstSimplification(withSimplification({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a simpler take whose end precedes its start", () => { + expect(resolveFirstSimplification(withSimplification({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length simpler take window", () => { + expect(resolveFirstSimplification(withSimplification({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a simpler take whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstSimplification( + withSimplification({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstSimplification(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withSimplification(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("returns null when role identities are duplicated", () => { + const song = withSimplification(); + 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: [] } + ]; + expect(resolveFirstSimplification(song)).toBeNull(); + }); + + it("bounds an oversized hint instead of dropping the next action", () => { + const song = withSimplification({ simplification: `${"Stay on roots. ".repeat(40)}end` }); + const resolved = resolveFirstSimplification(song); + expect(resolved?.hint.length).toBe(180); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSimplification.ts b/apps/desktop/src/features/workspace/firstSimplification.ts new file mode 100644 index 000000000..87661ade8 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSimplification.ts @@ -0,0 +1,262 @@ +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 MAX_HINT_CHARACTERS = 180; + +/** Tonight's first simpler take: the earliest named easier pass and the part that owns it. */ +export type FirstSimplification = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative simplification time as m:ss for rehearsal copy. */ +export function formatSimplificationTime(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; +} + +/** Return a bounded owned simplification hint, or null when the field cannot be shown. */ +function ownedSimplificationHint(role: RehearsalRole): string | null { + if (!hasOwnData(role, "simplification") || typeof role.simplification !== "string") { + return null; + } + const hint = role.simplification.trim(); + if (hint.length === 0) { + return null; + } + return hint.length <= MAX_HINT_CHARACTERS ? hint : hint.slice(0, MAX_HINT_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy, ranked priority, and a named simpler take. */ +function hasRankedSimplification(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) && + ownedSimplificationHint(role) !== null + ); +} + +/** 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 highest-priority ranked role, then a locale-independent stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active and who name a simpler take. */ +function rankedActiveSimplificationRoles(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) && + hasRankedSimplification(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Resolve a simpler take after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstSimplification(song: RehearsalSong): FirstSimplification | 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) && + rankedActiveSimplificationRoles(section).length > 0 + ) + .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 = pickHighestPriorityRole(rankedActiveSimplificationRoles(section)); + const hint = holdingRole ? ownedSimplificationHint(holdingRole) : null; + if (holdingRole === null || hint === null) { + return null; + } + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + hint + }; +} + +/** Return the first named simpler take, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstSimplification(song: RehearsalSong): FirstSimplification | null { + try { + return resolveSafeFirstSimplification(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..28efd608f 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", "chorus")).toBe("chorus"); + }); + + 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-simplification next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstSimplificationOpenAction")).toBe("{at} {role} 쉬운 패스 위치 열기"); + expect(t("firstSimplificationBody")).toBe("{at} {section}에서 {role} 파트가 더 쉽게 칠 수 있습니다."); + expect(t("firstSimplificationArmed")).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 d803a765e..e3ea75322 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -154,5 +154,10 @@ "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", "sectionRangeLabel": "Range", - "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." + "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.", + "firstSimplificationLabel": "Tonight's simpler take", + "firstSimplificationOpenAction": "Open {role} simpler take at {at}", + "firstSimplificationBody": "{role} can play simpler in the {section} at {at}.", + "firstSimplificationArmed": "Use the simpler take with {role} at {at}. Get through it together.", + "firstSimplificationUnavailable": "No simpler take yet. Stay on tonight's map until a part names an easier pass." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..38de8a0bb 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,5 +154,10 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "sectionRangeLabel": "음역", - "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." + "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.", + "firstSimplificationLabel": "오늘 첫 쉬운 패스", + "firstSimplificationOpenAction": "{at} {role} 쉬운 패스 위치 열기", + "firstSimplificationBody": "{at} {section}에서 {role} 파트가 더 쉽게 칠 수 있습니다.", + "firstSimplificationArmed": "{at}에서 {role} 파트와 함께 쉬운 패스로 넘기세요. 같이 통과하세요.", + "firstSimplificationUnavailable": "아직 쉬운 패스가 없습니다. 파트가 더 쉬운 연주를 표시할 때까지 오늘 지도에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..d4a7f20ae 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstSimplification.ts", + "src/features/workspace/FirstSimplificationCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..19fd4d556 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 Simplification Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `simplification` hint, the labeled section start, and the time. Do not invent an easier pass from `setupNote`, cue text, overlap warnings, or empty/whitespace hints. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-range, first-setup, first-lyric, and first-priority 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-simplification-navigation.md b/docs/doctoring/reduced-motion-first-simplification-navigation.md new file mode 100644 index 000000000..4613f27aa --- /dev/null +++ b/docs/doctoring/reduced-motion-first-simplification-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-simplification navigation + +Workspace map navigation for tonight's first simpler take follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstSimplificationCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Simplification resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, part-graph, and `simplification` strings are runtime data; inherited properties, accessors, setup notes, cues, overlap warnings, and arrays masquerading as record metadata are not authority. +- Trust boundary: simplification 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 hint is rendered as a separate text node and is never rescanned as template syntax. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, hints are trimmed and bounded, `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: inherited song/section/timing/role/graph/simplification metadata is rejected, array-backed section records are rejected, setup notes and cues cannot invent a simpler take, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From c61e065d600226da37102e807f76fd08db6b4316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:38:00 -0700 Subject: [PATCH 2/8] test(workspace): cover Unicode simplification truncation --- .../src/features/workspace/firstSimplification.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstSimplification.test.ts b/apps/desktop/src/features/workspace/firstSimplification.test.ts index 03efe1488..4dc14120e 100644 --- a/apps/desktop/src/features/workspace/firstSimplification.test.ts +++ b/apps/desktop/src/features/workspace/firstSimplification.test.ts @@ -168,4 +168,11 @@ describe("resolveFirstSimplification", () => { expect(resolved?.hint.length).toBe(180); expect(resolved?.holdingRole?.id).toBe("bass-guitar"); }); + + it("does not split a Unicode surrogate pair at the hint boundary", () => { + const song = withSimplification({ simplification: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstSimplification(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); }); From f369816f953c218aa64dc82ad64d3bec7a58fdf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:38:36 -0700 Subject: [PATCH 3/8] fix(workspace): truncate simplification hints by Unicode code point --- .../features/workspace/firstSimplification.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstSimplification.ts b/apps/desktop/src/features/workspace/firstSimplification.ts index 87661ade8..f84aa131b 100644 --- a/apps/desktop/src/features/workspace/firstSimplification.ts +++ b/apps/desktop/src/features/workspace/firstSimplification.ts @@ -65,6 +65,20 @@ function isDenseRuntimeArray(value: unknown): value is unknown[] { 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 a bounded owned simplification hint, or null when the field cannot be shown. */ function ownedSimplificationHint(role: RehearsalRole): string | null { if (!hasOwnData(role, "simplification") || typeof role.simplification !== "string") { @@ -74,7 +88,7 @@ function ownedSimplificationHint(role: RehearsalRole): string | null { if (hint.length === 0) { return null; } - return hint.length <= MAX_HINT_CHARACTERS ? hint : hint.slice(0, MAX_HINT_CHARACTERS); + return truncateCodePoints(hint, MAX_HINT_CHARACTERS); } /** Return true when the role has safe owned identity/copy, ranked priority, and a named simpler take. */ From d95307aa6d91c00b8ebe317cdf55e329dd4391dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:19:24 -0700 Subject: [PATCH 4/8] test(workspace): preserve simplification state across edits --- .../FirstSimplificationCallout.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx index 76d718086..38092dab9 100644 --- a/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx @@ -88,6 +88,27 @@ describe("FirstSimplificationCallout", () => { grid.remove(); }); + it("keeps armed guidance across unrelated immutable edits to the same song", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar simpler take at 0:10" })); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + + const updatedSong = structuredClone(initialSong); + updatedSong.sections[0]!.roles[0]!.practiceProgress = 60; + rerender(); + + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + + grid.remove(); + }); + it("names the first simpler take as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); From 9de003720fd71664f498e74a7ac7739173f47777 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:19:58 -0700 Subject: [PATCH 5/8] fix(workspace): preserve simplification state across edits --- .../workspace/FirstSimplificationCallout.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx index 5df22b884..5576c89bd 100644 --- a/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx @@ -31,6 +31,27 @@ function formatSimplificationCopy(template: string, values: SimplificationCopyVa }); } +/** Use a stable own song id when available, otherwise retain object identity without invoking accessors. */ +function stableSongIdentity(song: unknown): unknown { + if (song === null || typeof song !== "object") { + return song; + } + try { + const descriptor = Object.getOwnPropertyDescriptor(song, "id"); + if ( + descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ) { + return descriptor.value; + } + } catch { + return song; + } + return song; +} + /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredSimplificationScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -43,7 +64,7 @@ function preferredSimplificationScrollBehavior(): ScrollBehavior { export function FirstSimplificationCallout({ song }: FirstSimplificationCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); - const songIdentity: unknown = song; + const songIdentity = stableSongIdentity(song); const runtimeSong = song as unknown as Partial | null; const simpler = resolveFirstSimplification(song); const sectionIndex = From 14fbe7c6fbbfa7f6b5975b835bdfdea9f3668d9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:16:04 -0700 Subject: [PATCH 6/8] test(workspace): cover localized simplification navigation target --- .../FirstSimplificationCallout.test.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx index 38092dab9..c4436a9c3 100644 --- a/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.test.tsx @@ -13,11 +13,12 @@ function songWithoutSimplification() { return song; } -function appendSongStructureTarget() { +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { const timeline = document.createElement("div"); timeline.setAttribute("role", "region"); - timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + 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(); @@ -128,6 +129,21 @@ describe("FirstSimplificationCallout", () => { 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 simpler take at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Use the simpler take with Bass Guitar at 0:10. Get through it together./) + ).toBeTruthy(); + + grid.remove(); + }); + it("does not claim map navigation completed when the rendered section target is missing", () => { render(); From 0b86c0cdad710db83ce059c91f189662b596a0a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:16:39 -0700 Subject: [PATCH 7/8] fix(workspace): decouple simplification navigation from accessible copy --- .../src/features/workspace/FirstSimplificationCallout.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx index 5576c89bd..c4c4f3a57 100644 --- a/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.tsx @@ -120,9 +120,7 @@ export function FirstSimplificationCallout({ song }: FirstSimplificationCalloutP type="button" className="mt-3 min-h-11 bg-gradient-to-r from-amber-300 to-rose-300 font-black text-slate-950" onClick={() => { - const renderer = document.querySelector( - '[role="region"][aria-label="Scrollable song structure timeline"]' - ); + const renderer = document.querySelector('[data-testid="song-structure-grid"]'); const target = sectionIndex >= 0 ? (renderer?.querySelector( From b65160084583f09423a6d1bd0ccc3ffafb20bc4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 09:33:59 +0000 Subject: [PATCH 8/8] test(workspace): target renderer-owned song-structure for reduced-motion scroll The Open action already looks up data-testid=song-structure-grid, not accessible copy. Point the reduced-motion fixture at that same renderer child so the immediate-scroll contract stays executable after the navigation decoupling. --- .../FirstSimplificationCallout.reduced-motion.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx index c47794389..61b19b162 100644 --- a/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSimplificationCallout.reduced-motion.test.tsx @@ -21,8 +21,7 @@ describe("FirstSimplificationCallout reduced motion", () => { })); const grid = document.createElement("div"); - grid.setAttribute("role", "region"); - grid.setAttribute("aria-label", "Scrollable song structure timeline"); + grid.dataset.testid = "song-structure-grid"; const target = document.createElement("div"); target.dataset.sectionIndex = "0"; const scrollIntoView = vi.fn();