From a7057c0ac2fb5dac5929a039d3f00196721df06c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 23 Aug 2026 21:31:16 +0000 Subject: [PATCH 01/12] feat(workspace): name tonight's first articulation plan on the map After analysis, the room can open the first owned articulation plan on the rehearsal map instead of inferring attack or sustain from groove, setup notes, or dynamics copy. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- ...tArticulationPlanCallout.identity.test.tsx | 21 ++ ...tArticulationPlanCallout.particle.test.tsx | 52 ++++ ...ulationPlanCallout.reduced-motion.test.tsx | 43 +++ .../FirstArticulationPlanCallout.test.tsx | 266 +++++++++++++++++ .../FirstArticulationPlanCallout.tsx | 179 +++++++++++ ...lationPlanCallout.workspace-scope.test.tsx | 54 ++++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- ...rticulationPlan.inherited-metadata.test.ts | 94 ++++++ .../workspace/firstArticulationPlan.test.ts | 279 ++++++++++++++++++ .../workspace/firstArticulationPlan.ts | 274 +++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 ++- apps/desktop/src/i18n/index.ts | 34 +++ apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- docs/design-system/component-contract.md | 1 + ...tion-first-articulation-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 6 + packages/shared-types/test/index.test.ts | 7 + 23 files changed, 1417 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstArticulationPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstArticulationPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstArticulationPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-articulation-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..7d842b326 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 articulation plan with the owning part when an active role is corroborated, the owned `articulationPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, tuning plans, dynamics plans, confirmed overrides, harmonic explanations, or confidence notes. - 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..d920665e3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-24 ## 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 articulation plan must name the owning part when corroborated, the owned `articulationPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, tuning plans, dynamics plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note, first-transposition-plan, first-tuning-plan, and first-dynamics-plan. - 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..b8f69a789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first articulation plan in the mounted rehearsal workspace so a part can lock the owned articulation 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..6f2c5711e 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 articulation plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, tuning plans, dynamics plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note, first-transposition-plan, first-tuning-plan, and first-dynamics-plan. `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/FirstArticulationPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.identity.test.tsx new file mode 100644 index 000000000..2daaa1538 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +it("gives co-mounted articulation-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first articulation plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.particle.test.tsx new file mode 100644 index 000000000..51b3cb064 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.particle.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +describe("FirstArticulationPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the articulation 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", + articulationPlan: "Keep the verse attack short so the chorus still has a longer sustain to land on." + } + ]; + 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/FirstArticulationPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..555f9b1d5 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.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 { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +describe("FirstArticulationPlanCallout 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 articulation at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx new file mode 100644 index 000000000..04c92bb9c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx @@ -0,0 +1,266 @@ +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 { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +const DEMO_ARTICULATION_PLAN = + "Keep the verse attack short so the chorus still has a longer sustain to land on."; + +function songWithArticulationPlan() { + 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("FirstArticulationPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithArticulationPlan(); + 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 articulation at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same articulation signature", () => { + const firstSong = songWithArticulationPlan(); + const nextSong = songWithArticulationPlan(); + 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 articulation at 0:10" })); + expect( + screen.getByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar still has an articulation plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithArticulationPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar articulation at 0:10" })); + expect( + screen.getByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Bass Guitar still has an articulation plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's articulation plan under the named holding part", () => { + const song = songWithArticulationPlan(); + song.sections[0]!.roles[0]!.articulationPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.articulationPlan = + "Tune the patch a half step down so the chorus still sits under the vocal."; + song.sections[0]!.roles[2]!.articulationPlan = "Keep concert pitch even if the band drops the last chorus."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has an articulation plan in the verse at 0:10.") + ).toBeTruthy(); + expect( + screen.getByText("Tune the patch a half step down so the chorus still sits under the vocal.") + ).toBeTruthy(); + expect(screen.queryByText("Keep concert pitch even if the band drops the last chorus.")).toBeNull(); + expect(screen.queryByText(DEMO_ARTICULATION_PLAN)).toBeNull(); + }); + + it("names the first articulation plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_ARTICULATION_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar articulation at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that articulation on Bass Guitar 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 articulation at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that articulation on Bass Guitar 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 articulation at 0:10" })); + + expect(screen.getByText("Bass Guitar still has an articulation plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithArticulationPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar articulation 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 articulation 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 articulation plan changes or returns later", () => { + const initialSong = songWithArticulationPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar articulation at 0:10" })); + expect( + screen.getByText(/Lock that articulation on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + const nextSong = songWithArticulationPlan(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Bass Guitar still has an articulation plan in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable articulation plan guidance-only", () => { + const song = songWithArticulationPlan(); + for (const role of song.sections[0]!.roles) { + role.articulationPlan = ""; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByRole("complementary", { name: "Tonight's first articulation plan" }) + ).toBeTruthy(); + expect( + screen.getByText( + "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." + ) + ).toBeTruthy(); + }); + + it("localizes the articulation-plan form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithArticulationPlan(); + song.sections[0]!.roles[0]!.name = "베이스"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 파트의 아티큘레이션 계획이 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned articulation plan as a text node instead of template syntax", () => { + const song = songWithArticulationPlan(); + song.sections[0]!.roles[1]!.articulationPlan = ""; + song.sections[0]!.roles[2]!.articulationPlan = ""; + song.sections[0]!.roles[0]!.articulationPlan = "Check {role} at {at}"; + 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/FirstArticulationPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx new file mode 100644 index 000000000..852f85f8c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx @@ -0,0 +1,179 @@ +import { useEffect, useId, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatArticulationPlanTime, resolveFirstArticulationPlan } from "./firstArticulationPlan"; + +/** Props for the first articulation-plan rehearsal callout. */ +export interface FirstArticulationPlanCalloutProps { + song: RehearsalSong; +} + +type ArticulationPlanCopyValues = Readonly>; + +type OpenedArticulationPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + articulationPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableArticulationPlanSongIdentity(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 articulation-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatArticulationPlanCopy(template: string, values: ArticulationPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof ArticulationPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredArticulationPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveArticulationPlanRenderer(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 articulation plan and open the matching rendered map section. */ +export function FirstArticulationPlanCallout({ song }: FirstArticulationPlanCalloutProps) { + const calloutId = `workspace-surface-articulation-plan-${useId()}`; + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableArticulationPlanSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const named = resolveFirstArticulationPlan(song); + const namedSectionIndex = + named && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(named.section) + : -1; + const [openedArticulationPlan, setOpenedArticulationPlan] = useState(null); + + useEffect(() => { + setOpenedArticulationPlan(null); + }, [ + songIdentity, + namedSectionIndex, + named?.section.id, + named?.holdingRole.id, + named?.articulationPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedArticulationPlan !== null && + openedArticulationPlan.songIdentity === songIdentity && + openedArticulationPlan.sectionId === named.section.id && + openedArticulationPlan.sectionIndex === namedSectionIndex && + openedArticulationPlan.holdingRoleId === named.holdingRole.id && + openedArticulationPlan.articulationPlan === named.articulationPlan && + openedArticulationPlan.atSeconds === named.atSeconds; + const at = formatArticulationPlanTime(named.atSeconds); + const copyValues: ArticulationPlanCopyValues = { + role: named.holdingRole.name, + section: translateSectionFormLabel(locale, named.section.label), + at + }; + const actionLabel = formatArticulationPlanCopy(t("firstArticulationPlanOpenAction"), copyValues); + const body = formatArticulationPlanCopy(t("firstArticulationPlanBody"), copyValues); + const armed = formatArticulationPlanCopy(t("firstArticulationPlanArmed"), copyValues); + + return ( + + ); +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..efceecded --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.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 { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +describe("FirstArticulationPlanCallout 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 articulation at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..7d4465bac 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first articulation plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + 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( + "Keep the verse attack short so the chorus still has a longer sustain to land on." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar articulation at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that articulation on Bass Guitar 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..5dad20f7c 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 { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; 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/firstArticulationPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstArticulationPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..00ba460f7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstArticulationPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstArticulationPlan } from "./firstArticulationPlan"; + +function songWithArticulationPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "articulation-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + articulationPlan: "Keep the verse attack short so the chorus still has a longer sustain to land on." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstArticulationPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithArticulationPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstArticulationPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithArticulationPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithArticulationPlan(); + Object.defineProperty(section.roles[0]!, "articulationPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile articulationPlan getter"); + } + }); + + expect(() => resolveFirstArticulationPlan(song)).not.toThrow(); + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable articulation-plan identity authority", () => { + const { song, section } = songWithArticulationPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "articulation-own"; + } + }); + + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("does not let inherited articulation plans establish the named copy", () => { + const { song, section } = songWithArticulationPlan(); + const inheritedRole = Object.create({ + articulationPlan: "Inherited articulation plan" + }) 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]; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithArticulationPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithArticulationPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstArticulationPlan.test.ts b/apps/desktop/src/features/workspace/firstArticulationPlan.test.ts new file mode 100644 index 000000000..4555c6662 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstArticulationPlan.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatArticulationPlanTime, resolveFirstArticulationPlan } from "./firstArticulationPlan"; + +const DEMO_ARTICULATION_PLAN = + "Keep the verse attack short so the chorus still has a longer sustain to land on."; + +function withArticulationSection( + overrides: { + id?: string; + start?: number; + end?: number; + articulationPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + functionLabel?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-articulation"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "lead-vocal"; + section.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "medium", + cue: { kind: "lyric", value: "city lights" }, + range: { lowestNote: "G#3", highestNote: "C#5" }, + setupNote: "Watch the breath before the last line of the verse.", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + overlapWarnings: ["Melodic overlap: competing with Keyboard 1 Right Hand."], + harmony: { + chord: "C#m7", + functionLabel: overrides.functionLabel ?? "vi melodic pull", + source: "model" + }, + harmonicExplanation: + "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.", + confidence: { + level: "high", + source: "user", + notes: "Singer confirmed the pickup phrasing in rehearsal notes." + }, + articulationPlan: + overrides.articulationPlan ?? + "Keep the verse attack short so the chorus still has a longer sustain to land on.", + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstArticulationPlan", () => { + it("picks the demo song's earliest high-priority articulation plan and the part that owns it", () => { + const resolved = resolveFirstArticulationPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.articulationPlan).toBe(DEMO_ARTICULATION_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatArticulationPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatArticulationPlanTime(Number.NaN)).toBe("0:00"); + expect(formatArticulationPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent an articulation plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, tuning plans, dynamics plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withArticulationSection(); + delete song.sections[0]!.roles[0]!.articulationPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = + "Keep the verse attack short so the chorus still has a longer sustain to land on."; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: "Keep the verse attack short so the chorus still has a longer sustain to land on." + }; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("skips a blank articulation plan", () => { + expect(resolveFirstArticulationPlan(withArticulationSection({ articulationPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line articulation plan", () => { + expect( + resolveFirstArticulationPlan(withArticulationSection({ articulationPlan: "Drop under the vocal.\nKeep the pickup." })) + ).toBeNull(); + }); + + it("prefers the earlier of two articulation plans", () => { + const song = withArticulationSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + articulationPlan: "Late articulation." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + articulationPlan: "Earlier articulation." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstArticulationPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.articulationPlan).toBe("Earlier articulation."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time articulation-plan ties with locale-independent id ordering", () => { + const song = withArticulationSection({ id: "ä-articulation", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-articulation"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstArticulationPlan(song)?.section.id).toBe("z-articulation"); + }); + + it("prefers a high-priority articulation part over a low-priority part in the same section", () => { + const song = withArticulationSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + articulationPlan: "Low-priority articulation." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + articulationPlan: "High-priority articulation." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstArticulationPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstArticulationPlan(song)?.articulationPlan).toBe("High-priority articulation."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withArticulationSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + articulationPlan: "ASCII articulation." + }; + 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(resolveFirstArticulationPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstArticulationPlan(song)?.articulationPlan).toBe("ASCII articulation."); + }); + + it("skips an articulation plan whose graph node is inactive", () => { + expect(resolveFirstArticulationPlan(withArticulationSection({ isActive: false }))).toBeNull(); + }); + + it("skips an articulation plan whose rehearsal window is unbounded", () => { + expect(resolveFirstArticulationPlan(withArticulationSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips an articulation plan whose end precedes its start", () => { + expect(resolveFirstArticulationPlan(withArticulationSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length articulation-plan window", () => { + expect(resolveFirstArticulationPlan(withArticulationSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips an articulation plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstArticulationPlan( + withArticulationSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstArticulationPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withArticulationSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("keeps the articulation plan unnamed when role identities are duplicated", () => { + const song = withArticulationSection(); + 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(resolveFirstArticulationPlan(song)).toBeNull(); + }); + + it("bounds the articulation plan to 180 Unicode code points", () => { + const song = withArticulationSection({ articulationPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstArticulationPlan(song); + expect(resolved?.articulationPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the articulation-plan boundary", () => { + const song = withArticulationSection({ articulationPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstArticulationPlan(song); + expect(Array.from(resolved?.articulationPlan ?? "")).toHaveLength(180); + expect(resolved?.articulationPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstArticulationPlan.ts b/apps/desktop/src/features/workspace/firstArticulationPlan.ts new file mode 100644 index 000000000..1e0785bae --- /dev/null +++ b/apps/desktop/src/features/workspace/firstArticulationPlan.ts @@ -0,0 +1,274 @@ +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_ARTICULATION_PLAN_CHARACTERS = 180; + +/** Tonight's first articulation plan: the earliest labeled section and the part that owns it. */ +export type FirstArticulationPlan = { + section: RehearsalSection; + holdingRole: RehearsalRole; + articulationPlan: string; + atSeconds: number; +}; + +/** Format a non-negative articulation-plan time as m:ss for rehearsal copy. */ +export function formatArticulationPlanTime(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 a bounded own articulation plan, or null when it cannot be shown. */ +function ownedArticulationPlan(role: unknown): string | null { + if (!isRuntimeObject(role) || !hasOwnData(role, "articulationPlan")) { + return null; + } + const articulationPlan = (role as { articulationPlan?: unknown }).articulationPlan; + if (typeof articulationPlan !== "string") { + return null; + } + const trimmed = articulationPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_ARTICULATION_PLAN_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 earlier 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 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) + ); +} + +/** Resolve an articulation plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstArticulationPlan(song: RehearsalSong): FirstArticulationPlan | 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) + ) + .flatMap((section) => { + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedArticulationPlan(role) !== null) + ); + if (!holdingRole) { + return []; + } + const articulationPlan = ownedArticulationPlan(holdingRole); + if (!articulationPlan) { + return []; + } + return [ + { + section, + holdingRole, + articulationPlan, + atSeconds: section.timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.section.id, right.section.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first named articulation plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstArticulationPlan(song: RehearsalSong): FirstArticulationPlan | null { + try { + return resolveSafeFirstArticulationPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..540418747 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 Korean section form label", () => { + 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-articulation-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstArticulationPlanOpenAction")).toBe("{at} {role} 아티큘레이션 열기"); + expect(t("firstArticulationPlanBody")).toBe("{at} {section}에서 {role} 파트의 아티큘레이션 계획이 있습니다."); + expect(t("firstArticulationPlanArmed")).toBe("{at}에서 {role} 파트의 아티큘레이션을 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 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,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +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: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** 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..2b418bf01 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,10 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstArticulationPlanLabel": "Tonight's first articulation plan", + "firstArticulationPlanOpenAction": "Open {role} articulation at {at}", + "firstArticulationPlanBody": "{role} still has an articulation plan in the {section} at {at}.", + "firstArticulationPlanArmed": "Lock that articulation on {role} at {at} before the room starts.", + "firstArticulationPlanUnavailable": "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..39e1e0fd2 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,10 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstArticulationPlanLabel": "오늘 첫 아티큘레이션 계획", + "firstArticulationPlanOpenAction": "{at} {role} 아티큘레이션 열기", + "firstArticulationPlanBody": "{at} {section}에서 {role} 파트의 아티큘레이션 계획이 있습니다.", + "firstArticulationPlanArmed": "{at}에서 {role} 파트의 아티큘레이션을 맞춘 다음 합주를 시작하세요.", + "firstArticulationPlanUnavailable": "아티큘레이션을 맞춰야 하는 파트가 없습니다. 합주용 아티큘레이션 카피가 있는 파트가 생길 때까지 오늘 맵에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c6614fa1e 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | +| First Articulation Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `articulationPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `tuningPlan`, `dynamicsPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-setup-note, first-transposition-plan, first-tuning-plan, and first-dynamics-plan. | | 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. | | 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. | diff --git a/docs/doctoring/reduced-motion-first-articulation-plan-navigation.md b/docs/doctoring/reduced-motion-first-articulation-plan-navigation.md new file mode 100644 index 000000000..a799dd0d8 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-articulation-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first articulation-plan navigation + +Open tonight's first articulation plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..c9e29d2d2 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + articulationPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +475,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", + articulationPlan: "Keep the verse attack short so the chorus still has a longer sustain to land on.", manualOverrides: [], overlapWarnings: [ "Density warning: competing with Keyboard Left Hand in low register." @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "articulationPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1555,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.articulationPlan !== undefined && typeof value.articulationPlan !== "string") { + return invalidField(`${path}.articulationPlan`); + } if (!isDenseArray(value.manualOverrides)) { return invalidField(`${path}.manualOverrides`); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..02a15c9f6 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -738,6 +738,7 @@ describe("shared type helpers", () => { expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); + expect(song.sections[0]?.roles[0]?.articulationPlan).toContain("longer sustain"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -1257,6 +1258,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].articulationPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.articulationPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From 48ff145728ff5295de3fa7b2f011428dd7d64bd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:47:05 -0700 Subject: [PATCH 02/12] test(workspace): require neutral articulation fallback copy --- ...ationPlanCallout.unavailable-copy.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstArticulationPlanCallout.unavailable-copy.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..fac40b4c9 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstArticulationPlanCallout } from "./FirstArticulationPlanCallout"; + +function songWithoutArticulationPlan() { + const song = createDemoRehearsalSong(); + for (const role of song.sections[0]!.roles) { + role.articulationPlan = ""; + } + return song; +} + +describe("FirstArticulationPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English articulation plan is unavailable", () => { + render(); + + expect(screen.getByText("No articulation plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean articulation plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 아티큘레이션 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); From 44110962714403f2786ff74002251064aa1e5704 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:47:44 -0700 Subject: [PATCH 03/12] fix(workspace): neutralize articulation unavailable copy --- apps/desktop/src/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 2b418bf01..be5e59764 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,5 +153,5 @@ "firstArticulationPlanOpenAction": "Open {role} articulation at {at}", "firstArticulationPlanBody": "{role} still has an articulation plan in the {section} at {at}.", "firstArticulationPlanArmed": "Lock that articulation on {role} at {at} before the room starts.", - "firstArticulationPlanUnavailable": "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." + "firstArticulationPlanUnavailable": "No articulation plan is available. Stay on tonight's map for the next rehearsal cue." } From 22883a834a7581f1b745e764e77188dc2618e548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:48:19 -0700 Subject: [PATCH 04/12] fix(workspace): localize neutral articulation fallback --- 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 39e1e0fd2..a66e7ed90 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,5 +153,5 @@ "firstArticulationPlanOpenAction": "{at} {role} 아티큘레이션 열기", "firstArticulationPlanBody": "{at} {section}에서 {role} 파트의 아티큘레이션 계획이 있습니다.", "firstArticulationPlanArmed": "{at}에서 {role} 파트의 아티큘레이션을 맞춘 다음 합주를 시작하세요.", - "firstArticulationPlanUnavailable": "아티큘레이션을 맞춰야 하는 파트가 없습니다. 합주용 아티큘레이션 카피가 있는 파트가 생길 때까지 오늘 맵에 머무르세요." + "firstArticulationPlanUnavailable": "사용 가능한 아티큘레이션 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." } From aeee17b09690aa14b4a3967f15f94fb50c64e2d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:48:42 -0700 Subject: [PATCH 05/12] docs(i18n): describe translator fallback contract --- apps/desktop/src/i18n/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index ff6e218d1..f5656ce01 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -39,7 +39,7 @@ const sectionFormLabels: Readonly Date: Sun, 23 Aug 2026 14:49:31 -0700 Subject: [PATCH 06/12] test(workspace): align articulation fallback assertions --- .../workspace/FirstArticulationPlanCallout.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx index 04c92bb9c..3203cc7ec 100644 --- a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.test.tsx @@ -38,9 +38,7 @@ describe("FirstArticulationPlanCallout", () => { render(); expect( - screen.getByText( - "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." - ) + screen.getByText("No articulation plan is available. Stay on tonight's map for the next rehearsal cue.") ).toBeTruthy(); }); @@ -237,9 +235,7 @@ describe("FirstArticulationPlanCallout", () => { screen.getByRole("complementary", { name: "Tonight's first articulation plan" }) ).toBeTruthy(); expect( - screen.getByText( - "Nothing still has an articulation plan. Stay on tonight's map until a part owns rehearsal-facing articulation copy." - ) + screen.getByText("No articulation plan is available. Stay on tonight's map for the next rehearsal cue.") ).toBeTruthy(); }); From 7b1ad37da36ec361f2f94c9f49b102be0d3ba18a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:50:12 -0700 Subject: [PATCH 07/12] test(workspace): reject non-contract articulation labels --- .../firstArticulationPlan.section-label.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstArticulationPlan.section-label.test.ts diff --git a/apps/desktop/src/features/workspace/firstArticulationPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstArticulationPlan.section-label.test.ts new file mode 100644 index 000000000..96a6f629b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstArticulationPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstArticulationPlan } from "./firstArticulationPlan"; + +describe("resolveFirstArticulationPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstArticulationPlan(song)).toBeNull(); + }); +}); From 039b68cf556d997c2a9145cc0250b93aa254f0c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:50:45 -0700 Subject: [PATCH 08/12] fix(workspace): reject non-contract articulation labels --- .../features/workspace/firstArticulationPlan.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstArticulationPlan.ts b/apps/desktop/src/features/workspace/firstArticulationPlan.ts index 1e0785bae..77c890753 100644 --- a/apps/desktop/src/features/workspace/firstArticulationPlan.ts +++ b/apps/desktop/src/features/workspace/firstArticulationPlan.ts @@ -1,5 +1,6 @@ import { MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, type RehearsalRole, type RehearsalSection, type RehearsalSong @@ -7,6 +8,7 @@ import { const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_ARTICULATION_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); /** Tonight's first articulation plan: the earliest labeled section and the part that owns it. */ export type FirstArticulationPlan = { @@ -109,6 +111,15 @@ function hasRankedPriority(role: RehearsalRole): boolean { ); } +/** Return whether a section owns a canonical form label from the shared contract. */ +function hasSupportedSectionLabel(section: RehearsalSection): boolean { + return ( + hasOwnData(section, "label") && + typeof section.label === "string" && + SECTION_FORM_LABEL_SET.has(section.label) + ); +} + /** Return whether a section owns a bounded, positive-length integer rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { if (!hasOwnData(section, "timeRange")) { @@ -226,9 +237,7 @@ function resolveSafeFirstArticulationPlan(song: RehearsalSong): FirstArticulatio .filter( (section) => isRuntimeObject(section) && - hasOwnData(section, "label") && - typeof section.label === "string" && - section.label.trim().length > 0 && + hasSupportedSectionLabel(section) && hasOwnData(section, "id") && typeof section.id === "string" && section.id.trim().length > 0 && From ac3697d7c0e093a80290a4114305890e9010d67c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:47:10 -0700 Subject: [PATCH 09/12] test(workspace): require articulation plan coverage ownership --- .../workspace/articulationCoverageContract.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/src/features/workspace/articulationCoverageContract.test.ts diff --git a/apps/desktop/src/features/workspace/articulationCoverageContract.test.ts b/apps/desktop/src/features/workspace/articulationCoverageContract.test.ts new file mode 100644 index 000000000..ff8c26832 --- /dev/null +++ b/apps/desktop/src/features/workspace/articulationCoverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the articulation-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstArticulationPlan.ts", + "src/features/workspace/FirstArticulationPlanCallout.tsx" + ]) + ); + }); +}); From 4d87fe58750808e2f171b287c4bf8708d466fc4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:47:23 -0700 Subject: [PATCH 10/12] fix(coverage): include articulation production paths --- apps/desktop/vite.config.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..dc400d0a6 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,18 @@ import { fileURLToPath } from "node:url"; const configDirectory = path.dirname(fileURLToPath(import.meta.url)); +/** Production files whose V8 coverage is owned by the desktop test gate. */ +export const DESKTOP_OWNED_PRODUCTION_COVERAGE = [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstArticulationPlan.ts", + "src/features/workspace/FirstArticulationPlanCallout.tsx" +]; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +31,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, From e632b1693dff3fd52958b50601a2769ad5d50a54 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 21:39:02 +0900 Subject: [PATCH 11/12] test(articulation): cover non-reduced motion and placeholder fallback Devin flagged two branches with no test coverage that sit under the 90% gate: matchMedia-present-but-not-reduced (smooth scroll) and the values[key] ?? placeholder fallback in formatArticulationPlanCopy. - export formatArticulationPlanCopy (mirrors fillRangeCopy precedent) - add smooth-scroll case to the reduced-motion suite - add copy-format unit tests incl. missing-value placeholder passthrough Evidence: Callout suite 14/14, motion 2/2, copy-format 3/3, resolver 18/18 (34 total across the touched files). --- ...ulationPlanCallout.reduced-motion.test.tsx | 31 +++++++++++++++++ .../FirstArticulationPlanCallout.tsx | 2 +- .../firstArticulationPlanCopy.test.ts | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/features/workspace/firstArticulationPlanCopy.test.ts diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx index 555f9b1d5..3523b1204 100644 --- a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.reduced-motion.test.tsx @@ -40,4 +40,35 @@ describe("FirstArticulationPlanCallout reduced motion", () => { grid.remove(); }); + + it("keeps smooth scrolling when matchMedia exists but motion is not reduced", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + 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"; + 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 articulation at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); }); diff --git a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx index 852f85f8c..07456df23 100644 --- a/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstArticulationPlanCallout.tsx @@ -44,7 +44,7 @@ function stableArticulationPlanSongIdentity(song: RehearsalSong): unknown { } /** Interpolate articulation-plan placeholders once so rehearsal data is never rescanned as template syntax. */ -function formatArticulationPlanCopy(template: string, values: ArticulationPlanCopyValues): string { +export function formatArticulationPlanCopy(template: string, values: ArticulationPlanCopyValues): string { return template.replace(/\{(role|section|at)\}/g, (placeholder) => { const key = placeholder.slice(1, -1) as keyof ArticulationPlanCopyValues; return values[key] ?? placeholder; diff --git a/apps/desktop/src/features/workspace/firstArticulationPlanCopy.test.ts b/apps/desktop/src/features/workspace/firstArticulationPlanCopy.test.ts new file mode 100644 index 000000000..0930bf747 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstArticulationPlanCopy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { formatArticulationPlanCopy } from "./FirstArticulationPlanCallout"; + +describe("formatArticulationPlanCopy", () => { + it("interpolates known placeholders from supplied values", () => { + expect( + formatArticulationPlanCopy("{role} leads {section} at {at}", { + role: "Drums", + section: "verse", + at: "0:10" + }) + ).toBe("Drums leads verse at 0:10"); + }); + + it("keeps a placeholder verbatim when its value is missing", () => { + const values = { section: "verse", at: "0:10" } as unknown as Parameters< + typeof formatArticulationPlanCopy + >[1]; + expect(formatArticulationPlanCopy("{role} leads {section} at {at}", values)).toBe( + "{role} leads verse at 0:10" + ); + }); + + it("leaves non-placeholder braces untouched", () => { + expect( + formatArticulationPlanCopy("play {section} then {stop}", { + role: "", + section: "chorus", + at: "" + }) + ).toBe("play chorus then {stop}"); + }); +}); From 25d777fb15f656d032ecf08ef5a16bfd1ca39e43 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 22:43:52 +0900 Subject: [PATCH 12/12] docs(palette): drop role=button from disabled-button wrapper example Aligns the tooltip-wrapper guidance with the later rule forbidding interactive roles on wrappers that contain native buttons. --- .jules/palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index 98395b1e4..8e49f4a4b 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -28,7 +28,7 @@ ## 2024-06-29 - 비활성화된 네이티브 버튼의 툴팁 차단 **Learning:** 네이티브 `