From 832109a2e8e14933cebae2adeea71b8340a0b483 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 19 Aug 2026 11:08:16 +0000 Subject: [PATCH 01/42] feat(workspace): guide tonight's first intro on map and player Name the labeled intro start so the room can count in together. The workspace action opens the matching map section. Player Hear exists only when the owning playback surface supplies a seek callback. Do not invent an intro from verse, chorus, pickup, stop, handoff, or the first unlabeled section. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 97 ++++++++++ apps/desktop/src/features/player/index.tsx | 70 ++++++- .../FirstIntroCallout.reduced-motion.test.tsx | 69 +++++++ .../workspace/FirstIntroCallout.test.tsx | 170 ++++++++++++++++ .../features/workspace/FirstIntroCallout.tsx | 152 +++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 44 +++++ .../src/features/workspace/Workspace.tsx | 3 + .../src/features/workspace/firstIntro.test.ts | 176 +++++++++++++++++ .../src/features/workspace/firstIntro.ts | 181 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 13 +- apps/desktop/src/i18n/index.ts | 13 ++ apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-intro-navigation.md | 14 ++ 19 files changed, 1020 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstIntroCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstIntro.test.ts create mode 100644 apps/desktop/src/features/workspace/firstIntro.ts create mode 100644 docs/doctoring/reduced-motion-first-intro-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..f8bf747c7 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 labeled intro with the holding part when an active role is corroborated, the labeled start, and the time so the next action is obvious. - 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..a7726f324 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. +- Workspace and player copy for tonight's first labeled intro must name the holding part when corroborated, the labeled start, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..fb5115492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's first labeled intro on the workspace and player so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..3dbbfc739 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). Workspace and player name tonight's first labeled intro so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. `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/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..97ab2c8f9 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first intro from this player.") + ).toBeTruthy(); + }); + + it("keeps the intro hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Drums start at 0:00" })).toBeNull(); + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + }); + + it("delegates the intro hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(0); + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithIntro(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithIntro(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithIntro(); + song.sections = [null, song.sections[1]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithIntro(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..acb800e90 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,46 +1,96 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstIntroCallout } from "../workspace/FirstIntroCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Return whether one runtime section is safe to summarize in the player. */ +function isPlayerSummarySection(value: unknown): value is RehearsalSection { + if (value === null || typeof value !== "object") { + return false; + } + const section = value as Partial; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) + ); +} + +/** Return dense, individually valid sections without trusting runtime collection metadata. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = song.sections as unknown; + if (!Array.isArray(sections)) { + return []; + } + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + for (let index = 0; index < length; index += 1) { + if (!(index in sections)) { + return []; + } + } + return sections.filter(isPlayerSummarySection); +} + +/** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

No song loaded. Start an analysis to use the player.

+

{t("firstIntroNeedsSong")}

); } + const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; + return (

{title}

+
- {song.title} + {songTitle} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section) => ( + {sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..e4592bc9a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstIntroCallout } from "./FirstIntroCallout"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("FirstIntroCallout 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"; + const target = document.createElement("div"); + const second = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + grid.appendChild(second); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx new file mode 100644 index 000000000..9db868a05 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx @@ -0,0 +1,170 @@ +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 { FirstIntroCallout } from "./FirstIntroCallout"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const second = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + grid.appendChild(second); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstIntroCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("names the first intro as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Drums intro at 0:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Count in with Drums at 0:00. Start 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 Drums intro at 0:00" })); + + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + expect(screen.queryByText(/Count in with Drums at 0:00. Start together./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearIntro = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(onHearIntro).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithIntro(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first intro changes or returns later", () => { + const initialSong = songWithIntro(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(screen.getByText(/Count in with Drums at 0:00. Start together./)).toBeTruthy(); + + const nextSong = songWithIntro(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 4, end: 12 }; + rerender(); + expect(screen.getByText("Drums starts the intro at 0:04.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable intro guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide start when no part holds the intro", () => { + const song = songWithIntro(); + song.sections[0]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first intro at 0:00" })).toBeTruthy(); + expect(screen.getByText("The band starts the intro at 0:00.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearIntro = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" })); + expect(onHearIntro).toHaveBeenCalledWith(0); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + }); + + it("localizes the intro form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithIntro(); + song.sections[0]!.roles[0]!.name = "드럼"; + + render(); + + expect(screen.getByText("드럼이 0:00 인트로에서 시작합니다.")).toBeTruthy(); + expect(screen.queryByText(/intro에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx new file mode 100644 index 000000000..500781481 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx @@ -0,0 +1,152 @@ +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 { formatIntroTime, resolveFirstIntro } from "./firstIntro"; + +/** Props for the first-intro rehearsal callout. */ +export interface FirstIntroCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearIntro?: (atSeconds: number) => void; +} + +type IntroCopyValues = Readonly>; + +type HeardIntro = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate intro placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatIntroCopy(template: string, values: IntroCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof IntroCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredIntroScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled intro and offer only an action that the current surface can execute. */ +export function FirstIntroCallout({ + song, + actionMode = "workspace-scroll", + onHearIntro +}: FirstIntroCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const intro = resolveFirstIntro(song); + const introSectionIndex = + intro && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(intro.section) + : -1; + const [heardIntro, setHeardIntro] = useState(null); + + useEffect(() => { + setHeardIntro(null); + }, [songId, introSectionIndex, intro?.section.id, intro?.holdingRole?.id, intro?.atSeconds]); + + if (!intro) { + return ( + + ); + } + + const heard = + heardIntro?.songId === songId && + heardIntro.sectionId === intro.section.id && + heardIntro.sectionIndex === introSectionIndex && + heardIntro.holdingRoleId === (intro.holdingRole?.id ?? null) && + heardIntro.atSeconds === intro.atSeconds; + const at = formatIntroTime(intro.atSeconds); + const copyValues: IntroCopyValues = { + role: intro.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, intro.section.label), + at + }; + const hasRole = intro.holdingRole !== null; + const actionLabel = formatIntroCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstIntroAction" + : "firstIntroActionBand" + : hasRole + ? "firstIntroOpenAction" + : "firstIntroOpenActionBand" + ), + copyValues + ); + const body = formatIntroCopy(t(hasRole ? "firstIntroBody" : "firstIntroBodyBand"), copyValues); + const armed = formatIntroCopy(t(hasRole ? "firstIntroArmed" : "firstIntroArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearIntro === "function"; + /** Record completion only after the owning surface has executed the selected intro action. */ + const markIntroActionComplete = () => { + setHeardIntro({ + songId, + sectionId: intro.section.id, + sectionIndex: introSectionIndex, + holdingRoleId: intro.holdingRole?.id ?? null, + atSeconds: intro.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..9e5851d20 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,48 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first intro as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + + 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 Drums intro at 0:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Count in with Drums at 0:00. Start together./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..fd01f7e60 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 { FirstIntroCallout } from "./FirstIntroCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstIntro.test.ts b/apps/desktop/src/features/workspace/firstIntro.test.ts new file mode 100644 index 000000000..800a1f5a9 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatIntroTime, resolveFirstIntro } from "./firstIntro"; + +function withIntroSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = overrides.id ?? "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: overrides.start ?? 0, end: overrides.end ?? 8 }; + const roleId = overrides.roleId ?? "drums"; + intro.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Drums", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + intro.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("resolveFirstIntro", () => { + it("returns null when the demo song has no labeled intro", () => { + expect(resolveFirstIntro(createDemoRehearsalSong())).toBeNull(); + expect(formatIntroTime(Number.NaN)).toBe("0:00"); + expect(formatIntroTime(-4)).toBe("0:00"); + }); + + it("does not invent an intro from a verse, chorus, pickup, stop, or handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + const pickup = structuredClone(verse); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [verse, pickup, stop, chorus, handoff]; + + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("does not treat the first unlabeled section as an intro", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { start: 0, end: 16 }; + expect(song.sections[0]!.label).toBe("verse"); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("picks the earliest labeled intro and the part that counts it in", () => { + const song = withIntroSection({ start: 0, end: 8 }); + const intro = resolveFirstIntro(song); + + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole?.id).toBe("drums"); + expect(intro?.atSeconds).toBe(0); + expect(formatIntroTime(intro?.atSeconds ?? -1)).toBe("0:00"); + }); + + it("prefers the earlier of two labeled intros", () => { + const song = withIntroSection({ id: "intro-late", start: 64, end: 72 }); + const verse = song.sections[1]!; + const earlier = structuredClone(song.sections[0]!); + earlier.id = "intro-early"; + earlier.timeRange = { start: 0, end: 8 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[1]!, song.sections[0]!, earlier]; + + const intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-early"); + expect(intro?.holdingRole?.id).toBe("bass-guitar"); + expect(intro?.atSeconds).toBe(0); + }); + + it("keeps a band-wide start when no active ranked role holds it", () => { + const song = withIntroSection({ isActive: false }); + const intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole).toBeNull(); + expect(intro?.atSeconds).toBe(0); + }); + + it("skips an intro whose rehearsal window is unbounded", () => { + const song = withIntroSection({ start: Number.NaN, end: 8 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips an intro whose end precedes its start", () => { + const song = withIntroSection({ start: 8, end: 0 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips a zero-length intro window", () => { + const song = withIntroSection({ start: 0, end: 0 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips an intro whose endpoint overflows the shared timing bound", () => { + const song = withIntroSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstIntro(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withIntroSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("keeps the start band-wide when role identities are duplicated", () => { + const song = withIntroSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts new file mode 100644 index 000000000..1d8b259c7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -0,0 +1,181 @@ +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; + +/** Tonight's first labeled intro: the earliest start and the part that counts it in. */ +export type FirstIntro = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative intro time as m:ss for rehearsal copy. */ +export function formatIntroTime(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}`; +} + +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return whether every numeric index is present 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 (!(index in value)) { + return false; + } + } + return true; +} + +/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + typeof role.name === "string" && + role.name.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + 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 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 left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => isRuntimeObject(node) && 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) && + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the first labeled intro, or null when no safe start remains. */ +export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const introSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "intro" && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return left.id.localeCompare(right.id); + }); + + const section = introSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..a48412e77 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,15 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes an intro label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "intro")).toBe("인트로"); + expect(translateSectionFormLabel("en", "intro")).toBe("intro"); + }); + + it("preserves unlabeled form values as data", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..656047036 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,13 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + en: { intro: "intro" }, + ko: { intro: "인트로" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for a section form label, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label] ?? 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..440e59408 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstIntroLabel": "Tonight's first intro", + "firstIntroAction": "Hear {role} start at {at}", + "firstIntroActionBand": "Hear the first intro at {at}", + "firstIntroOpenAction": "Open {role} intro at {at}", + "firstIntroOpenActionBand": "Open the first intro at {at}", + "firstIntroBody": "{role} starts the {section} at {at}.", + "firstIntroBodyBand": "The band starts the {section} at {at}.", + "firstIntroArmed": "Count in with {role} at {at}. Start together.", + "firstIntroArmedBand": "Count in at {at}. Start together.", + "firstIntroUnavailable": "No intro yet. Stay on tonight's map until the start is labeled.", + "firstIntroNeedsSong": "Analyze tonight's song first, then hear the first intro from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..16f0e5161 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstIntroLabel": "오늘 첫 인트로", + "firstIntroAction": "{at}에 {role} 시작 듣기", + "firstIntroActionBand": "{at} 첫 인트로 듣기", + "firstIntroOpenAction": "{at} {role} 인트로 위치 열기", + "firstIntroOpenActionBand": "{at} 첫 인트로 위치 열기", + "firstIntroBody": "{role}이 {at} {section}에서 시작합니다.", + "firstIntroBodyBand": "밴드가 {at} {section}에서 시작합니다.", + "firstIntroArmed": "{at}에서 {role}과 함께 카운트인하세요. 같이 시작하세요.", + "firstIntroArmedBand": "{at}에서 카운트인하세요. 같이 시작하세요.", + "firstIntroUnavailable": "아직 인트로가 없습니다. 시작이 표시될 때까지 오늘 지도에 머무르세요.", + "firstIntroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 인트로를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..e9ad457dd 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 Intro Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstIntroCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `intro` start, and the time. Do not invent a start from `verse`, `chorus`, `pickup`, `stop`, `handoff`, or the first unlabeled section. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearIntro` exists and delegates the exact intro second to that callback. Keep the unavailable state guidance-only. Distinct from loop-first-section #903, first-notes #905, first-chorus #939, and first-stop #934. | | 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-intro-navigation.md b/docs/doctoring/reduced-motion-first-intro-navigation.md new file mode 100644 index 000000000..c454bff9a --- /dev/null +++ b/docs/doctoring/reduced-motion-first-intro-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-intro navigation + +Workspace map navigation for tonight's first intro follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstIntroCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Intro resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. From ff219f51a50f4ab1b922e7401413b863b118dc71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:16:50 -0700 Subject: [PATCH 02/42] test(workspace): lock intro tie-breaking across locales --- .../src/features/workspace/firstIntro.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstIntro.test.ts b/apps/desktop/src/features/workspace/firstIntro.test.ts index 800a1f5a9..27e2a3950 100644 --- a/apps/desktop/src/features/workspace/firstIntro.test.ts +++ b/apps/desktop/src/features/workspace/firstIntro.test.ts @@ -118,6 +118,28 @@ describe("resolveFirstIntro", () => { expect(intro?.atSeconds).toBe(0); }); + it("breaks same-time intro ties with locale-independent id ordering", () => { + const song = withIntroSection({ id: "ä-intro", start: 0, end: 8 }); + const asciiIntro = structuredClone(song.sections[0]!); + asciiIntro.id = "z-intro"; + song.sections = [song.sections[0]!, asciiIntro]; + + expect(resolveFirstIntro(song)?.section.id).toBe("z-intro"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withIntroSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const intro = song.sections[0]!; + const asciiRole = { ...intro.roles[0]!, id: "z-role", name: "ASCII role" }; + intro.roles = [intro.roles[0]!, asciiRole]; + intro.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstIntro(song)?.holdingRole?.id).toBe("z-role"); + }); + it("keeps a band-wide start when no active ranked role holds it", () => { const song = withIntroSection({ isActive: false }); const intro = resolveFirstIntro(song); From ebfd15b4476badf5890647157505acb9b4e7fca6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:17:29 -0700 Subject: [PATCH 03/42] fix(workspace): make intro tie-breaking locale independent --- .../src/features/workspace/firstIntro.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index 1d8b259c7..98e8d5d40 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -24,6 +24,17 @@ export function formatIntroTime(totalSeconds: number): string { 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 an object. */ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object"; @@ -90,7 +101,7 @@ function repeatedIds(ids: string[]): Set { return repeated; } -/** Prefer the highest-priority ranked role, then a stable id order. */ +/** 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; @@ -101,7 +112,7 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { if (rankDelta !== 0) { return rankDelta; } - return left.id.localeCompare(right.id); + return compareStableId(left.id, right.id); })[0] ?? null ); } @@ -165,7 +176,7 @@ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { if (left.timeRange.start !== right.timeRange.start) { return left.timeRange.start - right.timeRange.start; } - return left.id.localeCompare(right.id); + return compareStableId(left.id, right.id); }); const section = introSections[0]; From 990f6f5fdf2ae364ef40e108bb10a31562a4f6fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:18:45 -0700 Subject: [PATCH 04/42] test(i18n): reject inherited section-label lookups --- apps/desktop/src/i18n/index.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index a48412e77..835d60117 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -85,5 +85,10 @@ describe("i18n", () => { it("preserves unlabeled form values as data", () => { expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); }); }); From 1e1a937e32ee722e98a9edaefa9ab5f655b208c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:19:11 -0700 Subject: [PATCH 05/42] test(player): require localized section-count copy --- apps/desktop/src/features/player/index.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 97ab2c8f9..8e8d6e331 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -55,6 +55,17 @@ describe("PlayerFeature", () => { expect(onPlayFromSeconds).toHaveBeenCalledWith(0); }); + it("localizes the section count instead of hard-coding English player copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + try { + render(); + expect(screen.getByText("2개 섹션")).toBeTruthy(); + expect(screen.queryByText("2 sections")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("renders a safe empty summary when the runtime section collection is not an array", () => { const song = songWithIntro(); (song as unknown as { sections: unknown }).sections = null; @@ -94,4 +105,4 @@ describe("PlayerFeature", () => { expect(() => render()).not.toThrow(); expect(screen.queryByText("not-copy")).toBeNull(); }); -}); +}); \ No newline at end of file From a41090ced6d2266ddeff0d0c23a66a2288ed87b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:19:55 -0700 Subject: [PATCH 06/42] fix(i18n): keep inherited keys out of section labels --- apps/desktop/src/i18n/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 656047036..aaab437ad 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -26,9 +26,10 @@ export function createTranslator(locale: Locale = "en") { }; } -/** Return localized copy for a section form label, preserving unknown labels as data. */ +/** Return localized copy for an own section-form entry, preserving unknown labels as data. */ export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { - return sectionFormLabels[locale][label] ?? label; + const labels = sectionFormLabels[locale]; + return Object.prototype.hasOwnProperty.call(labels, label) ? (labels[label] ?? label) : label; } /** Documented. */ @@ -38,4 +39,4 @@ export function detectPreferredLocale(): Locale { } return "en"; -} +} \ No newline at end of file From 85c425d69b4bca7b5066013c8762530c015a03e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:20:23 -0700 Subject: [PATCH 07/42] fix(player): localize section-count labels --- apps/desktop/src/features/player/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index acb800e90..3864acafd 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -47,7 +47,8 @@ function playerSummarySections(song: RehearsalSong): RehearsalSection[] { /** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { - const t = createTranslator(detectPreferredLocale()); + const locale = detectPreferredLocale(); + const t = createTranslator(locale); if (!song) { return ( @@ -60,6 +61,9 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP const sections = playerSummarySections(song); const songTitle = typeof song.title === "string" ? song.title : ""; + const sectionCountLabel = t( + sections.length === 1 ? "metricConfidenceSectionSingular" : "metricConfidenceSectionPlural" + ); return (
@@ -77,7 +81,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
{songTitle} - {sections.length} {sections.length === 1 ? "section" : "sections"} + {sections.length} {sectionCountLabel}
@@ -103,4 +107,4 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
); -} +} \ No newline at end of file From 9f4dd360c992824634a56e4d29a77ebc6db2564c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:28:04 +0900 Subject: [PATCH 08/42] fix(player): localize section count copy --- apps/desktop/src/features/player/index.tsx | 10 ++++++---- apps/desktop/src/locales/en/common.json | 2 ++ apps/desktop/src/locales/ko/common.json | 2 ++ .../doctoring/reduced-motion-first-intro-navigation.md | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 3864acafd..92427f1d6 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -62,8 +62,10 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP const sections = playerSummarySections(song); const songTitle = typeof song.title === "string" ? song.title : ""; const sectionCountLabel = t( - sections.length === 1 ? "metricConfidenceSectionSingular" : "metricConfidenceSectionPlural" - ); + sections.length === 1 + ? "metricConfidenceSectionCountSingular" + : "metricConfidenceSectionCountPlural" + ).replace("{count}", String(sections.length)); return (
@@ -81,7 +83,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
{songTitle} - {sections.length} {sectionCountLabel} + {sectionCountLabel}
@@ -107,4 +109,4 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 440e59408..df550e4e8 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "Local analysis", "metricConfidenceSectionSingular": "section", "metricConfidenceSectionPlural": "sections", + "metricConfidenceSectionCountSingular": "{count} section", + "metricConfidenceSectionCountPlural": "{count} sections", "metricPriorityFallback": "Pick track", "metricPriorityPendingDetail": "Choose or open audio", "loadProjectFailedPrefix": "Failed to load project", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 16f0e5161..34ed01521 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "로컬 분석", "metricConfidenceSectionSingular": "구간", "metricConfidenceSectionPlural": "구간", + "metricConfidenceSectionCountSingular": "{count}개 섹션", + "metricConfidenceSectionCountPlural": "{count}개 섹션", "metricPriorityFallback": "트랙 선택", "metricPriorityPendingDetail": "오디오를 선택하거나 여세요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", diff --git a/docs/doctoring/reduced-motion-first-intro-navigation.md b/docs/doctoring/reduced-motion-first-intro-navigation.md index c454bff9a..3ffee7107 100644 --- a/docs/doctoring/reduced-motion-first-intro-navigation.md +++ b/docs/doctoring/reduced-motion-first-intro-navigation.md @@ -8,7 +8,7 @@ This is a presentation contract only. Intro resolution, action-mode authority, a ## Security Notes -- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Untrusted input: song, section, and role identifiers are used as copy values, local completion-state identity, and effect dependencies; they are not DOM-ID authority. - Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. - Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. - Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. From 862cae42abf31d704692fe1918fb0ccc44181c01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:44:34 -0700 Subject: [PATCH 09/42] test(player): require localized intro badge --- apps/desktop/src/features/player/index.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 8e8d6e331..6ef8c6772 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -55,12 +55,14 @@ describe("PlayerFeature", () => { expect(onPlayFromSeconds).toHaveBeenCalledWith(0); }); - it("localizes the section count instead of hard-coding English player copy", () => { + it("localizes the section count and labeled intro badge instead of mixing English player copy", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); try { render(); expect(screen.getByText("2개 섹션")).toBeTruthy(); expect(screen.queryByText("2 sections")).toBeNull(); + expect(screen.getByText("인트로")).toBeTruthy(); + expect(screen.queryByText("intro")).toBeNull(); } finally { vi.unstubAllGlobals(); } From 9a7a4652ae9dc82f709b05ca15aa5ca047b012b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:44:59 -0700 Subject: [PATCH 10/42] fix(player): localize intro section badge --- apps/desktop/src/features/player/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 92427f1d6..82afafd00 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -5,7 +5,7 @@ import { type SectionFormLabel } from "@bandscope/shared-types"; import { FirstIntroCallout } from "../workspace/FirstIntroCallout"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; type PlayerFeatureProps = { title: string; @@ -99,7 +99,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP textTransform: "capitalize" }} > - {section.label} + {translateSectionFormLabel(locale, section.label)} ))} From 8b48181bdd5c4a73cf17bed7af32bddafc561242 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:20:59 -0700 Subject: [PATCH 11/42] test(workspace): lock particle-safe Korean intro copy --- .../FirstIntroCallout.particle.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx new file mode 100644 index 000000000..8e6a198b9 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstIntroCallout } from "./FirstIntroCallout"; + +describe("FirstIntroCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const intro = structuredClone(seed); + intro.id = "intro-particle"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; + intro.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [intro]; + + render(); + + expect(screen.getByText("0:00 인트로에서 피아노 파트가 시작합니다.")).toBeTruthy(); + expect(screen.queryByText("피아노이 0:00 인트로에서 시작합니다.")).toBeNull(); + }); +}); From d3d3c404785e0aba35aeefd9c2f8e72b9ecaf8f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:21:50 -0700 Subject: [PATCH 12/42] fix(workspace): keep Korean intro role copy particle-safe --- 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 34ed01521..a78bab66b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -156,7 +156,7 @@ "firstIntroActionBand": "{at} 첫 인트로 듣기", "firstIntroOpenAction": "{at} {role} 인트로 위치 열기", "firstIntroOpenActionBand": "{at} 첫 인트로 위치 열기", - "firstIntroBody": "{role}이 {at} {section}에서 시작합니다.", + "firstIntroBody": "{at} {section}에서 {role} 파트가 시작합니다.", "firstIntroBodyBand": "밴드가 {at} {section}에서 시작합니다.", "firstIntroArmed": "{at}에서 {role}과 함께 카운트인하세요. 같이 시작하세요.", "firstIntroArmedBand": "{at}에서 카운트인하세요. 같이 시작하세요.", From 0cb60b57df4239062cea82673e60b0c381544f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:19:59 -0700 Subject: [PATCH 13/42] test(workspace): cover Korean intro armed particle safety --- .../FirstIntroCallout.particle.test.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx index 8e6a198b9..6574110fc 100644 --- a/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FirstIntroCallout } from "./FirstIntroCallout"; @@ -8,7 +8,7 @@ describe("FirstIntroCallout Korean role copy", () => { vi.unstubAllGlobals(); }); - it("keeps vowel-ending dynamic role names particle-safe", () => { + it("keeps vowel-ending dynamic role names particle-safe before and after the intro action", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); const song = createDemoRehearsalSong(); const seed = song.sections[0]!; @@ -22,9 +22,16 @@ describe("FirstIntroCallout Korean role copy", () => { ]; song.sections = [intro]; - render(); + const onHearIntro = vi.fn(); + render(); expect(screen.getByText("0:00 인트로에서 피아노 파트가 시작합니다.")).toBeTruthy(); - expect(screen.queryByText("피아노이 0:00 인트로에서 시작합니다.")).toBeNull(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:00에 피아노 시작 듣기" })); + + expect(onHearIntro).toHaveBeenCalledWith(0); + expect(screen.getByText("0:00에서 피아노 파트와 함께 카운트인하세요. 같이 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); }); -}); +}); \ No newline at end of file From 93f49dc589163152861db87f28ad5efb6171361f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:20:51 -0700 Subject: [PATCH 14/42] fix(workspace): keep Korean intro armed copy particle-safe --- 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 a78bab66b..f97150e3c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -158,7 +158,7 @@ "firstIntroOpenActionBand": "{at} 첫 인트로 위치 열기", "firstIntroBody": "{at} {section}에서 {role} 파트가 시작합니다.", "firstIntroBodyBand": "밴드가 {at} {section}에서 시작합니다.", - "firstIntroArmed": "{at}에서 {role}과 함께 카운트인하세요. 같이 시작하세요.", + "firstIntroArmed": "{at}에서 {role} 파트와 함께 카운트인하세요. 같이 시작하세요.", "firstIntroArmedBand": "{at}에서 카운트인하세요. 같이 시작하세요.", "firstIntroUnavailable": "아직 인트로가 없습니다. 시작이 표시될 때까지 오늘 지도에 머무르세요.", "firstIntroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 인트로를 들으세요." From b35424c63012199e24708610c27b62747ba8197d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:21:30 -0700 Subject: [PATCH 15/42] test(workspace): align Korean intro copy oracle --- apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx index 9db868a05..afbeb8824 100644 --- a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx @@ -164,7 +164,7 @@ describe("FirstIntroCallout", () => { render(); - expect(screen.getByText("드럼이 0:00 인트로에서 시작합니다.")).toBeTruthy(); + expect(screen.getByText("0:00 인트로에서 드럼 파트가 시작합니다.")).toBeTruthy(); expect(screen.queryByText(/intro에서/)).toBeNull(); }); }); From c9180f7b35bbf055b4f84e038efd81a0c213c034 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:37:58 -0700 Subject: [PATCH 16/42] docs(changelog): preserve tempo badge entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5115492..6aa8be0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - Name tonight's first labeled intro on the workspace and player so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. From 26b7f1098564d34858babc3e2c89ea288f40e0f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:08:08 -0700 Subject: [PATCH 17/42] test(workspace): reject inherited intro metadata --- .../firstIntro.inherited-metadata.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts diff --git a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts new file mode 100644 index 000000000..49a04e33f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts @@ -0,0 +1,50 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstIntro } from "./firstIntro"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const intro = structuredClone(song.sections[0]!); + intro.id = "intro-own"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + song.sections = [intro]; + return { song, intro }; +} + +describe("resolveFirstIntro inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, intro } = songWithIntro(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstIntro(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(intro) as typeof intro; + song.sections = [inheritedSection]; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, intro } = songWithIntro(); + intro.timeRange = Object.create({ start: 0, end: 8 }) as typeof intro.timeRange; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, intro } = songWithIntro(); + const role = intro.roles[0]!; + const node = intro.partGraph[0]!; + intro.roles = [Object.create(role) as typeof role]; + intro.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, intro } = songWithIntro(); + const arraySection = Object.assign([], intro) as unknown as typeof intro; + song.sections = [arraySection]; + expect(resolveFirstIntro(song)).toBeNull(); + }); +}); From cbea560cab0c730ffc3f31047fe63d56be615f4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:08:38 -0700 Subject: [PATCH 18/42] fix(workspace): reject inherited intro metadata --- .../src/features/workspace/firstIntro.ts | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index 98e8d5d40..e25c9c500 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -35,12 +35,17 @@ function compareStableId(left: string, right: string): number { return 0; } -/** Return whether an untrusted runtime value can be inspected as an object. */ +/** 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"; + return value !== null && typeof value === "object" && !Array.isArray(value); } -/** Return whether every numeric index is present in a bounded runtime array. */ +/** Return whether a runtime record owns the named field rather than inheriting it. */ +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** Return whether every numeric index is an own element in a bounded runtime array. */ function isDenseRuntimeArray(value: unknown): value is unknown[] { if (!Array.isArray(value)) { return false; @@ -50,28 +55,38 @@ function isDenseRuntimeArray(value: unknown): value is unknown[] { return false; } for (let index = 0; index < length; index += 1) { - if (!(index in value)) { + if (!hasOwn(value, index)) { return false; } } return true; } -/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { return ( + hasOwn(role, "id") && typeof role.id === "string" && role.id.trim().length > 0 && + hasOwn(role, "name") && typeof role.name === "string" && role.name.trim().length > 0 && + hasOwn(role, "rehearsalPriority") && Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) ); } -/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwn(section, "timeRange")) { + return false; + } const timeRange = section.timeRange as Partial | null; - if (timeRange === null || typeof timeRange !== "object") { + if ( + !isRuntimeObject(timeRange) || + !hasOwn(timeRange, "start") || + !hasOwn(timeRange, "end") + ) { return false; } @@ -119,18 +134,31 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { /** Return ranked roles whose unique graph node is explicitly active. */ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { - if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { + if ( + !hasOwn(section, "roles") || + !hasOwn(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { return []; } const safeRoleIds = section.roles .filter( - (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + (role) => + isRuntimeObject(role) && + hasOwn(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 ) .map((role) => role.id); const safeGraphRoleIds = section.partGraph .filter( - (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0 + (node) => + isRuntimeObject(node) && + hasOwn(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 ) .map((node) => node.role_id); const repeatedRoleIds = repeatedIds(safeRoleIds); @@ -140,7 +168,9 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { .filter( (node) => isRuntimeObject(node) && + hasOwn(node, "is_active") && node.is_active === true && + hasOwn(node, "role_id") && typeof node.role_id === "string" && node.role_id.trim().length > 0 && !repeatedGraphRoleIds.has(node.role_id) @@ -159,7 +189,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled intro, or null when no safe start remains. */ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { - if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { return null; } @@ -167,7 +197,9 @@ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { .filter( (section) => isRuntimeObject(section) && + hasOwn(section, "label") && section.label === "intro" && + hasOwn(section, "id") && typeof section.id === "string" && section.id.trim().length > 0 && hasBoundedTimeRange(section) From f06234b9d0dbaa2901d59bcc2d9ae723fc73bc1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:09:07 -0700 Subject: [PATCH 19/42] docs(workspace): record intro metadata trust boundary --- docs/doctoring/reduced-motion-first-intro-navigation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/reduced-motion-first-intro-navigation.md b/docs/doctoring/reduced-motion-first-intro-navigation.md index 3ffee7107..eb2286a17 100644 --- a/docs/doctoring/reduced-motion-first-intro-navigation.md +++ b/docs/doctoring/reduced-motion-first-intro-navigation.md @@ -8,7 +8,7 @@ This is a presentation contract only. Intro resolution, action-mode authority, a ## Security Notes -- Untrusted input: song, section, and role identifiers are used as copy values, local completion-state identity, and effect dependencies; they are not DOM-ID authority. -- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. -- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. -- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. +- Untrusted input: song, section, time-range, role, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: intro 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. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: inherited song/section/timing/role/graph metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From 37f6de828a0a911438f85aeb15963ad36780df7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:11:38 -0700 Subject: [PATCH 20/42] test(i18n): require Korean section badge labels --- apps/desktop/src/i18n/index.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index 835d60117..f5e6523a8 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -77,13 +77,19 @@ describe("i18n", () => { }); describe("translateSectionFormLabel", () => { - it("localizes an intro label for Korean rehearsal copy", () => { + it("localizes every supported section-form label for Korean rehearsal copy", () => { expect(translateSectionFormLabel("ko", "intro")).toBe("인트로"); + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("ko", "bridge")).toBe("브리지"); + expect(translateSectionFormLabel("ko", "outro")).toBe("아웃트로"); + expect(translateSectionFormLabel("ko", "tag")).toBe("태그"); + expect(translateSectionFormLabel("ko", "pickup")).toBe("픽업"); + expect(translateSectionFormLabel("ko", "stop")).toBe("스톱"); + expect(translateSectionFormLabel("ko", "handoff")).toBe("핸드오프"); expect(translateSectionFormLabel("en", "intro")).toBe("intro"); - }); - - it("preserves unlabeled form values as data", () => { - expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); }); it("does not treat inherited object keys as localized section labels", () => { @@ -91,4 +97,4 @@ describe("i18n", () => { expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); }); }); -}); +}); \ No newline at end of file From 36a93c9d2ce644ae21512b333fb4f1eb53244709 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:11:55 -0700 Subject: [PATCH 21/42] fix(i18n): localize all section form badges --- apps/desktop/src/i18n/index.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index aaab437ad..c0b89ffd0 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -13,10 +13,32 @@ const dictionaries = { } as const; const sectionFormLabels: Readonly< - Record>> + Record>> > = { - en: { intro: "intro" }, - ko: { intro: "인트로" } + 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. */ From 41063fe9d0678371a41cc29e08bb49780439fafb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:31:58 -0700 Subject: [PATCH 22/42] test(workspace): reject exceptional intro metadata --- .../firstIntro.inherited-metadata.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts index 49a04e33f..252ac9bfd 100644 --- a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts @@ -47,4 +47,47 @@ describe("resolveFirstIntro inherited metadata", () => { song.sections = [arraySection]; expect(resolveFirstIntro(song)).toBeNull(); }); + + it("contains an own timeRange accessor that throws", () => { + const { song, intro } = songWithIntro(); + Object.defineProperty(intro, "timeRange", { + configurable: true, + enumerable: true, + get() { + throw new Error("timeRange getter must stay data"); + } + }); + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("contains a nested timeRange Proxy whose descriptor trap throws", () => { + const { song, intro } = songWithIntro(); + intro.timeRange = new Proxy( + { start: 0, end: 8 }, + { + getOwnPropertyDescriptor() { + throw new Error("timeRange descriptor trap"); + } + } + ) as typeof intro.timeRange; + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("contains a section Proxy whose descriptor trap throws", () => { + const { song, intro } = songWithIntro(); + song.sections = [ + new Proxy(intro, { + getOwnPropertyDescriptor() { + throw new Error("section descriptor trap"); + } + }) + ]; + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); }); From 785dbef2d1fcda4dfcf5cb3c77613290d33577f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 19:33:23 -0700 Subject: [PATCH 23/42] fix(workspace): contain exceptional intro metadata --- .../src/features/workspace/firstIntro.ts | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index e25c9c500..f8dc2b4f1 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -40,9 +40,23 @@ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object" && !Array.isArray(value); } -/** Return whether a runtime record owns the named field rather than inheriting it. */ +/** Return whether a runtime record owns the named field without letting Proxy traps escape. */ function hasOwn(value: object, key: PropertyKey): boolean { - return Object.prototype.hasOwnProperty.call(value, key); + try { + return Object.prototype.hasOwnProperty.call(value, key); + } catch { + return false; + } +} + +/** Read an own data property without invoking accessors or letting Proxy descriptor traps escape. */ +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } } /** Return whether every numeric index is an own element in a bounded runtime array. */ @@ -78,24 +92,19 @@ function hasRankedPriority(role: RehearsalRole): boolean { /** Return whether a section owns a bounded, positive-length integer rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { - if (!hasOwn(section, "timeRange")) { - return false; - } - const timeRange = section.timeRange as Partial | null; - if ( - !isRuntimeObject(timeRange) || - !hasOwn(timeRange, "start") || - !hasOwn(timeRange, "end") - ) { + const timeRange = readOwnDataProperty(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { return false; } - const start = timeRange.start ?? -1; - const end = timeRange.end ?? -1; + const start = readOwnDataProperty(timeRange, "start"); + const end = readOwnDataProperty(timeRange, "end"); return ( + typeof start === "number" && Number.isInteger(start) && start >= 0 && start <= MAX_SECTION_TIME_SECONDS && + typeof end === "number" && Number.isInteger(end) && end > start && end <= MAX_SECTION_TIME_SECONDS @@ -189,36 +198,40 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled intro, or null when no safe start remains. */ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { - if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { - return null; - } + try { + if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } - const introSections = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasOwn(section, "label") && - section.label === "intro" && - hasOwn(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) - ) - .sort((left, right) => { - if (left.timeRange.start !== right.timeRange.start) { - return left.timeRange.start - right.timeRange.start; - } - return compareStableId(left.id, right.id); - }); + const introSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwn(section, "label") && + section.label === "intro" && + hasOwn(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = introSections[0]; + if (!section) { + return null; + } - const section = introSections[0]; - if (!section) { + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; + } catch { return null; } - - return { - section, - holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), - atSeconds: section.timeRange.start - }; } From 3f6217a3ab0722f393ec6a34221c9cc80ec1952d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:07:18 -0700 Subject: [PATCH 24/42] test(runtime): bound synthetic section collection scans --- .../runtimeSectionCollection.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx diff --git a/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx b/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx new file mode 100644 index 000000000..dcd90a2e1 --- /dev/null +++ b/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx @@ -0,0 +1,60 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSection } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { PlayerFeature } from "../player"; +import { resolveFirstIntro } from "./firstIntro"; + +function syntheticLengthSections() { + const target: RehearsalSection[] = []; + target.length = 256; + let numericDescriptorReads = 0; + + const sections = new Proxy(target, { + getOwnPropertyDescriptor(current, property) { + if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) { + numericDescriptorReads += 1; + return { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }; + } + return Reflect.getOwnPropertyDescriptor(current, property); + }, + has(current, property) { + if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) { + throw new Error("numeric membership scan must stay bounded by materialized keys"); + } + return Reflect.has(current, property); + }, + ownKeys() { + return ["length"]; + } + }); + + return { + sections, + numericDescriptorReads: () => numericDescriptorReads + }; +} + +describe("runtime section collection bounds", () => { + it("does not scan a synthetic array length while resolving the first intro", () => { + const song = createDemoRehearsalSong(); + const hostile = syntheticLengthSections(); + song.sections = hostile.sections; + + expect(resolveFirstIntro(song)).toBeNull(); + expect(hostile.numericDescriptorReads()).toBeLessThan(8); + }); + + it("fails closed without numeric membership scanning in the player summary", () => { + const song = createDemoRehearsalSong(); + const hostile = syntheticLengthSections(); + song.sections = hostile.sections; + + expect(() => render()).not.toThrow(); + expect(hostile.numericDescriptorReads()).toBeLessThan(8); + }); +}); From bd5b1eaf642207672a588740623fcbb925d36f80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:08:43 -0700 Subject: [PATCH 25/42] fix(runtime): bound intro section density scans --- .../desktop/src/features/workspace/firstIntro.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index f8dc2b4f1..660e959cd 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -59,21 +59,21 @@ function readOwnDataProperty(value: object, key: PropertyKey): unknown { } } -/** Return whether every numeric index is an own element in a bounded runtime array. */ +/** Return whether materialized enumerable keys exactly cover a runtime array's numeric indices. */ 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 (!hasOwn(value, index)) { + try { + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { return false; } + const keys = Object.keys(value); + return keys.length === length && keys.every((key, index) => key === String(index)); + } catch { + return false; } - return true; } /** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ From 8b1e4022ee5786a85f9add17fafe41dbcf4852b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:09:06 -0700 Subject: [PATCH 26/42] fix(player): bound runtime section density scans --- apps/desktop/src/features/player/index.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 82afafd00..8b2ae346a 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -27,22 +27,25 @@ function isPlayerSummarySection(value: unknown): value is RehearsalSection { ); } -/** Return dense, individually valid sections without trusting runtime collection metadata. */ +/** Return dense, individually valid sections without scanning attacker-controlled synthetic lengths. */ function playerSummarySections(song: RehearsalSong): RehearsalSection[] { const sections = song.sections as unknown; if (!Array.isArray(sections)) { return []; } - const length = Number(sections.length); - if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { - return []; - } - for (let index = 0; index < length; index += 1) { - if (!(index in sections)) { + try { + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { return []; } + const keys = Object.keys(sections); + if (keys.length !== length || !keys.every((key, index) => key === String(index))) { + return []; + } + return sections.filter(isPlayerSummarySection); + } catch { + return []; } - return sections.filter(isPlayerSummarySection); } /** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */ From b473ab530e794f5d3e16ee2754ec0c75f5e7ef3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:13:28 -0700 Subject: [PATCH 27/42] refactor(i18n): remove unreachable label fallback branch --- apps/desktop/src/i18n/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index c0b89ffd0..3dca55b4e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -51,7 +51,7 @@ export function createTranslator(locale: Locale = "en") { /** Return localized copy for an own section-form entry, preserving unknown labels as data. */ export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { const labels = sectionFormLabels[locale]; - return Object.prototype.hasOwnProperty.call(labels, label) ? (labels[label] ?? label) : label; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : label; } /** Documented. */ @@ -61,4 +61,4 @@ export function detectPreferredLocale(): Locale { } return "en"; -} \ No newline at end of file +} From 0f62b379388370f7494112d6be0976c208b9204f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:18:28 -0700 Subject: [PATCH 28/42] test(player): localize playback availability hint --- apps/desktop/src/features/player/index.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 6ef8c6772..70851b643 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -55,7 +55,7 @@ describe("PlayerFeature", () => { expect(onPlayFromSeconds).toHaveBeenCalledWith(0); }); - it("localizes the section count and labeled intro badge instead of mixing English player copy", () => { + it("localizes the section count, labels, and playback hint instead of mixing English player copy", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); try { render(); @@ -63,6 +63,8 @@ describe("PlayerFeature", () => { expect(screen.queryByText("2 sections")).toBeNull(); expect(screen.getByText("인트로")).toBeTruthy(); expect(screen.queryByText("intro")).toBeNull(); + expect(screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.")).toBeTruthy(); + expect(screen.queryByText("Audio playback requires the desktop app with a local audio source.")).toBeNull(); } finally { vi.unstubAllGlobals(); } From 45a877bfad078ea80a4849eb412e9b30fccdda91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:19:13 -0700 Subject: [PATCH 29/42] feat(i18n): add localized player playback hint --- apps/desktop/src/locales/en/common.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index df550e4e8..d91b9d73c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -161,5 +161,6 @@ "firstIntroArmed": "Count in with {role} at {at}. Start together.", "firstIntroArmedBand": "Count in at {at}. Start together.", "firstIntroUnavailable": "No intro yet. Stay on tonight's map until the start is labeled.", - "firstIntroNeedsSong": "Analyze tonight's song first, then hear the first intro from this player." + "firstIntroNeedsSong": "Analyze tonight's song first, then hear the first intro from this player.", + "playerPlaybackRequiresDesktop": "Audio playback requires the desktop app with a local audio source." } From 8cbe4b30a9f7ec2bfd2cf11dfffe0c4e5e823587 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:19:39 -0700 Subject: [PATCH 30/42] feat(i18n): add Korean player playback hint --- apps/desktop/src/locales/ko/common.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index f97150e3c..6a7ed3654 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -161,5 +161,6 @@ "firstIntroArmed": "{at}에서 {role} 파트와 함께 카운트인하세요. 같이 시작하세요.", "firstIntroArmedBand": "{at}에서 카운트인하세요. 같이 시작하세요.", "firstIntroUnavailable": "아직 인트로가 없습니다. 시작이 표시될 때까지 오늘 지도에 머무르세요.", - "firstIntroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 인트로를 들으세요." + "firstIntroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 인트로를 들으세요.", + "playerPlaybackRequiresDesktop": "오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다." } From ff9ab4492ac59c1a49580659f6bdf17b9e8d63fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:20:08 -0700 Subject: [PATCH 31/42] fix(player): localize playback availability hint --- apps/desktop/src/features/player/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 8b2ae346a..098a4bbb8 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -107,7 +107,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP ))}
- Audio playback requires the desktop app with a local audio source. + {t("playerPlaybackRequiresDesktop")}
From 9b7a1c63b7018878b0cdb82a1d34436f59882ebc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:47:24 -0700 Subject: [PATCH 32/42] test(workspace): require localized timeline summary --- .../workspace/Workspace.localization.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.localization.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.localization.test.tsx b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx new file mode 100644 index 000000000..cb6c25596 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace timeline localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps the Korean workspace timeline summary in Korean", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect( + screen.getByText(`${song.sections.length}개 섹션에 그루브, 역할 큐, 코드 신뢰도 메모가 매핑되어 있습니다.`) + ).toBeTruthy(); + expect(screen.queryByText(/mapped with groove, role cues, and chord confidence notes/i)).toBeNull(); + }); +}); From 3984ba04ceefe9d6c21fdcc5f7b549f14df3a285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:47:44 -0700 Subject: [PATCH 33/42] test(workspace): use shared localized timeline labels --- .../src/features/workspace/Workspace.localization.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.localization.test.tsx b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx index cb6c25596..8bb2bfae9 100644 --- a/apps/desktop/src/features/workspace/Workspace.localization.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx @@ -24,7 +24,7 @@ describe("Workspace timeline localization", () => { render(); expect( - screen.getByText(`${song.sections.length}개 섹션에 그루브, 역할 큐, 코드 신뢰도 메모가 매핑되어 있습니다.`) + screen.getByText(`${song.sections.length}개 섹션 · 그루브 · 역할 · 코드 · 신뢰도`) ).toBeTruthy(); expect(screen.queryByText(/mapped with groove, role cues, and chord confidence notes/i)).toBeNull(); }); From 3e81d49ae58d1eec12fbbab70ccb9d4a2bd383d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:49:19 -0700 Subject: [PATCH 34/42] fix(workspace): localize timeline summary --- apps/desktop/src/features/workspace/Workspace.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index fd01f7e60..50d2a65b1 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -213,6 +213,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const sectionCountLabel = t( + song.sections.length === 1 + ? "metricConfidenceSectionCountSingular" + : "metricConfidenceSectionCountPlural" + ).replace("{count}", String(song.sections.length)); /** Documented. */ const handleExportCueSheet = () => { @@ -293,7 +298,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceSongTimelineLabel")}

- {song.sections.length} section{song.sections.length === 1 ? "" : "s"} mapped with groove, role cues, and chord confidence notes. + {sectionCountLabel} · {t("sectionGrooveLabel")} · {t("navRoles")} · {t("sectionChordLabel")} · {t("roleConfidence")}

From 96985e1ff8226927b9dc9ef20ac235b3de794cc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:07:22 -0700 Subject: [PATCH 35/42] test(workspace): cover summary term capitalization --- .../workspace/Workspace.summary-copy.test.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx b/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx new file mode 100644 index 000000000..9887f5610 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace summary copy", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps English workspace summary terms capitalized consistently", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText(/Groove · Roles · Chord · Confidence/)).toBeTruthy(); + expect(screen.queryByText(/Groove · Roles · Chord · confidence/)).toBeNull(); + }); +}); From 6d38625470951902f00c8b64bda211ff07c333f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 01:09:13 -0700 Subject: [PATCH 36/42] fix(workspace): capitalize confidence summary label --- 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 d91b9d73c..80c579e01 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -12,7 +12,7 @@ "sourceModeReference": "References the original file", "unsupportedLocalAudio": "Choose a WAV, MP3, FLAC, or M4A file to start analysis.", "sectionConfidence": "Section confidence", - "roleConfidence": "confidence", + "roleConfidence": "Confidence", "harmonySource": "harmony source", "manualOverride": "manual override", "startAnalysis": "Start analysis", From 14db3d710f805bd58ebb46fe8abd6a829c9008b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:09:33 -0700 Subject: [PATCH 37/42] test(workspace): isolate malformed first-intro candidates --- .../firstIntro.inherited-metadata.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts index 252ac9bfd..be36d02ab 100644 --- a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts @@ -90,4 +90,38 @@ describe("resolveFirstIntro inherited metadata", () => { expect(() => resolveFirstIntro(song)).not.toThrow(); expect(resolveFirstIntro(song)).toBeNull(); }); + + it("skips a throwing malformed candidate and preserves a later valid intro", () => { + const { song, intro } = songWithIntro(); + const malformed = structuredClone(intro); + malformed.id = "broken-intro"; + Object.defineProperty(malformed, "label", { + configurable: true, + enumerable: true, + get() { + throw new Error("candidate label getter must not poison later intros"); + } + }); + song.sections = [malformed, intro]; + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.atSeconds).toBe(0); + }); + + it("keeps a valid intro when one role exposes a throwing identity accessor", () => { + const { song, intro } = songWithIntro(); + const role = intro.roles[0]!; + Object.defineProperty(role, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("role id getter must not poison the intro"); + } + }); + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.holdingRole).toBeNull(); + }); }); From 7ad095694ddbe90e85b474ef5e215477d86920d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:10:18 -0700 Subject: [PATCH 38/42] fix(workspace): isolate malformed first-intro metadata --- .../src/features/workspace/firstIntro.ts | 220 ++++++++++-------- 1 file changed, 117 insertions(+), 103 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index 660e959cd..cbec11e14 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -7,6 +7,18 @@ import { const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +type RankedRoleCandidate = { + role: RehearsalRole; + id: string; + priority: keyof typeof PRIORITY_RANK; +}; + +type IntroSectionCandidate = { + section: RehearsalSection; + id: string; + start: number; +}; + /** Tonight's first labeled intro: the earliest start and the part that counts it in. */ export type FirstIntro = { section: RehearsalSection; @@ -40,15 +52,6 @@ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object" && !Array.isArray(value); } -/** Return whether a runtime record owns the named field without letting Proxy traps escape. */ -function hasOwn(value: object, key: PropertyKey): boolean { - try { - return Object.prototype.hasOwnProperty.call(value, key); - } catch { - return false; - } -} - /** Read an own data property without invoking accessors or letting Proxy descriptor traps escape. */ function readOwnDataProperty(value: object, key: PropertyKey): unknown { try { @@ -76,39 +79,71 @@ function isDenseRuntimeArray(value: unknown): value is unknown[] { } } -/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ -function hasRankedPriority(role: RehearsalRole): boolean { - return ( - hasOwn(role, "id") && - typeof role.id === "string" && - role.id.trim().length > 0 && - hasOwn(role, "name") && - typeof role.name === "string" && - role.name.trim().length > 0 && - hasOwn(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 { +/** Snapshot the bounded integer rehearsal window from own data properties only. */ +function readBoundedTimeRange(section: object): { start: number; end: number } | null { const timeRange = readOwnDataProperty(section, "timeRange"); if (!isRuntimeObject(timeRange)) { - return false; + return null; } const start = readOwnDataProperty(timeRange, "start"); const end = readOwnDataProperty(timeRange, "end"); - return ( - typeof start === "number" && - Number.isInteger(start) && - start >= 0 && - start <= MAX_SECTION_TIME_SECONDS && - typeof end === "number" && - Number.isInteger(end) && - end > start && - end <= MAX_SECTION_TIME_SECONDS - ); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Snapshot the ranking fields needed from one untrusted role without invoking accessors. */ +function readRankedRoleCandidate(value: unknown): RankedRoleCandidate | null { + if (!isRuntimeObject(value)) { + return null; + } + const id = readOwnDataProperty(value, "id"); + const name = readOwnDataProperty(value, "name"); + const priority = readOwnDataProperty(value, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof priority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, priority) + ) { + return null; + } + return { + role: value as RehearsalRole, + id, + priority: priority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a valid labeled intro candidate so later sorting cannot invoke untrusted accessors. */ +function readIntroSectionCandidate(value: unknown): IntroSectionCandidate | null { + if (!isRuntimeObject(value)) { + return null; + } + const label = readOwnDataProperty(value, "label"); + const id = readOwnDataProperty(value, "id"); + const timeRange = readBoundedTimeRange(value); + if (label !== "intro" || typeof id !== "string" || id.trim().length === 0 || !timeRange) { + return null; + } + return { + section: value as RehearsalSection, + id, + start: timeRange.start + }; } /** Return safe identities that appear more than once in one section-local collection. */ @@ -126,110 +161,89 @@ function repeatedIds(ids: string[]): Set { } /** Prefer the highest-priority ranked role, then a locale-independent stable id order. */ -function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { - if (roles.length === 0) { +function pickHighestPriorityRole(candidates: RankedRoleCandidate[]): RehearsalRole | null { + if (candidates.length === 0) { return null; } return ( - [...roles].sort((left, right) => { - const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + [...candidates].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.priority] - PRIORITY_RANK[right.priority]; if (rankDelta !== 0) { return rankDelta; } return compareStableId(left.id, right.id); - })[0] ?? null + })[0]?.role ?? null ); } /** Return ranked roles whose unique graph node is explicitly active. */ -function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { - if ( - !hasOwn(section, "roles") || - !hasOwn(section, "partGraph") || - !isDenseRuntimeArray(section.roles) || - !isDenseRuntimeArray(section.partGraph) - ) { +function rankedActiveRoles(section: RehearsalSection): RankedRoleCandidate[] { + const roles = readOwnDataProperty(section, "roles"); + const partGraph = readOwnDataProperty(section, "partGraph"); + if (!isDenseRuntimeArray(roles) || !isDenseRuntimeArray(partGraph)) { return []; } - const safeRoleIds = section.roles - .filter( - (role) => - isRuntimeObject(role) && - hasOwn(role, "id") && - typeof role.id === "string" && - role.id.trim().length > 0 - ) - .map((role) => role.id); - const safeGraphRoleIds = section.partGraph - .filter( - (node) => - isRuntimeObject(node) && - hasOwn(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 roleCandidates = roles + .map(readRankedRoleCandidate) + .filter((candidate): candidate is RankedRoleCandidate => candidate !== null); + const repeatedRoleIds = repeatedIds(roleCandidates.map((candidate) => candidate.id)); + + const graphCandidates = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = readOwnDataProperty(node, "role_id"); + const isActive = readOwnDataProperty(node, "is_active"); + if (typeof roleId !== "string" || roleId.trim().length === 0) { + return []; + } + return [{ roleId, isActive }]; + }); + const repeatedGraphRoleIds = repeatedIds(graphCandidates.map((candidate) => candidate.roleId)); const activeIds = new Set( - section.partGraph + graphCandidates .filter( - (node) => - isRuntimeObject(node) && - hasOwn(node, "is_active") && - node.is_active === true && - hasOwn(node, "role_id") && - typeof node.role_id === "string" && - node.role_id.trim().length > 0 && - !repeatedGraphRoleIds.has(node.role_id) + (candidate) => candidate.isActive === true && !repeatedGraphRoleIds.has(candidate.roleId) ) - .map((node) => node.role_id) + .map((candidate) => candidate.roleId) ); - return section.roles.filter( - (role) => - isRuntimeObject(role) && - hasRankedPriority(role) && - !repeatedRoleIds.has(role.id) && - activeIds.has(role.id) + return roleCandidates.filter( + (candidate) => !repeatedRoleIds.has(candidate.id) && activeIds.has(candidate.id) ); } /** Return the first labeled intro, or null when no safe start remains. */ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { try { - if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + if (!isRuntimeObject(song)) { + return null; + } + const sections = readOwnDataProperty(song, "sections"); + if (!isDenseRuntimeArray(sections)) { return null; } - const introSections = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasOwn(section, "label") && - section.label === "intro" && - hasOwn(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) - ) + const introSections = sections + .map(readIntroSectionCandidate) + .filter((candidate): candidate is IntroSectionCandidate => candidate !== null) .sort((left, right) => { - if (left.timeRange.start !== right.timeRange.start) { - return left.timeRange.start - right.timeRange.start; + if (left.start !== right.start) { + return left.start - right.start; } return compareStableId(left.id, right.id); }); - const section = introSections[0]; - if (!section) { + const candidate = introSections[0]; + if (!candidate) { return null; } return { - section, - holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), - atSeconds: section.timeRange.start + section: candidate.section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(candidate.section)), + atSeconds: candidate.start }; } catch { return null; From b627d3155ba1c212c078f67cd4869bbae5a76ba8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:12:30 -0700 Subject: [PATCH 39/42] test(player): reject accessor-backed section collections --- .../src/features/player/index.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 70851b643..c80b86751 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -92,6 +92,37 @@ describe("PlayerFeature", () => { expect(screen.getByText("0 sections")).toBeTruthy(); }); + it("renders a safe empty summary when sections is a throwing own accessor", () => { + const song = songWithIntro(); + Object.defineProperty(song, "sections", { + configurable: true, + enumerable: true, + get() { + throw new Error("sections getter must stay data"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when a song Proxy throws on sections access", () => { + const song = songWithIntro(); + const proxiedSong = new Proxy(song, { + get(target, key, receiver) { + if (key === "sections") { + throw new Error("sections get trap"); + } + return Reflect.get(target, key, receiver); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + it("omits malformed runtime section elements without crashing the player summary", () => { const song = songWithIntro(); song.sections = [null, song.sections[1]!] as unknown as typeof song.sections; From 4855357e19dd444271f95142667cb226989e1142 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:12:51 -0700 Subject: [PATCH 40/42] fix(player): contain section collection accessors --- apps/desktop/src/features/player/index.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 098a4bbb8..83c1e992d 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -13,6 +13,16 @@ type PlayerFeatureProps = { onPlayFromSeconds?: (startSeconds: number) => void; }; +/** Read an own data property without invoking accessors or letting descriptor traps escape. */ +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + /** Return whether one runtime section is safe to summarize in the player. */ function isPlayerSummarySection(value: unknown): value is RehearsalSection { if (value === null || typeof value !== "object") { @@ -27,9 +37,9 @@ function isPlayerSummarySection(value: unknown): value is RehearsalSection { ); } -/** Return dense, individually valid sections without scanning attacker-controlled synthetic lengths. */ +/** Return dense, individually valid sections without invoking an untrusted collection accessor. */ function playerSummarySections(song: RehearsalSong): RehearsalSection[] { - const sections = song.sections as unknown; + const sections = readOwnDataProperty(song, "sections"); if (!Array.isArray(sections)) { return []; } From 39bad566d629ab045259dd8a34239ebda7bbdedf Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 20:56:27 +0900 Subject: [PATCH 41/42] fix(workspace): read intro sections through trap-safe own-data path FirstIntroCallout read runtimeSong.sections directly for indexOf, so a throwing getter or Proxy get-trap crashed the render after resolveFirstIntro had already validated via descriptor reads. Route both the identity and section-index reads through descriptor-only helpers that fail closed (-1/empty), matching the player's trust-boundary posture. Tests: FirstIntroCallout suite 13/13 (adds throwing-accessor and get-trap-Proxy cases), Workspace suite 12/12. --- .../workspace/FirstIntroCallout.test.tsx | 34 +++++++++++++++++++ .../features/workspace/FirstIntroCallout.tsx | 18 ++++++---- .../src/features/workspace/firstIntro.ts | 19 +++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx index afbeb8824..1120823b9 100644 --- a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx @@ -59,6 +59,40 @@ describe("FirstIntroCallout", () => { ).toBeTruthy(); }); + it("contains a throwing own sections accessor instead of crashing the callout", () => { + const song = songWithIntro(); + Object.defineProperty(song, "sections", { + configurable: true, + enumerable: true, + get() { + throw new Error("sections getter must stay data"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("contains a song Proxy that throws on sections access instead of crashing the callout", () => { + const song = songWithIntro(); + const proxiedSong = new Proxy(song, { + get(target, key, receiver) { + if (key === "sections") { + throw new Error("sections get trap"); + } + return Reflect.get(target, key, receiver); + } + }); + + // Descriptor-based reads forward past get traps by design, so the intro still + // resolves safely instead of the render crashing on a direct sections read. + expect(() => render()).not.toThrow(); + expect(screen.getByLabelText("Tonight's first intro")).toBeTruthy(); + expect(screen.queryByText(/No intro yet/i)).toBeNull(); + }); + it("names the first intro as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx index 500781481..51ff84e3b 100644 --- a/apps/desktop/src/features/workspace/FirstIntroCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx @@ -6,7 +6,7 @@ import { detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; -import { formatIntroTime, resolveFirstIntro } from "./firstIntro"; +import { formatIntroTime, resolveFirstIntro, resolveFirstIntroSectionIndex } from "./firstIntro"; /** Props for the first-intro rehearsal callout. */ export interface FirstIntroCalloutProps { @@ -50,12 +50,18 @@ export function FirstIntroCallout({ const locale = detectPreferredLocale(); const t = createTranslator(locale); const runtimeSong = song as unknown as Partial | null; - const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + let songId = ""; + try { + const idDescriptor = runtimeSong + ? Object.getOwnPropertyDescriptor(runtimeSong, "id") + : undefined; + const rawId = idDescriptor && "value" in idDescriptor ? idDescriptor.value : undefined; + songId = typeof rawId === "string" ? rawId : ""; + } catch { + songId = ""; + } const intro = resolveFirstIntro(song); - const introSectionIndex = - intro && Array.isArray(runtimeSong?.sections) - ? runtimeSong.sections.indexOf(intro.section) - : -1; + const introSectionIndex = intro ? resolveFirstIntroSectionIndex(song, intro.section) : -1; const [heardIntro, setHeardIntro] = useState(null); useEffect(() => { diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts index cbec11e14..62d257c14 100644 --- a/apps/desktop/src/features/workspace/firstIntro.ts +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -214,6 +214,25 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleCandidate[] { ); } +/** Locate a resolved intro's section inside the song's own-data sections array, failing closed to -1. */ +export function resolveFirstIntroSectionIndex( + song: RehearsalSong, + section: RehearsalSection +): number { + try { + if (!isRuntimeObject(song)) { + return -1; + } + const sections = readOwnDataProperty(song, "sections"); + if (!isDenseRuntimeArray(sections)) { + return -1; + } + return sections.indexOf(section); + } catch { + return -1; + } +} + /** Return the first labeled intro, or null when no safe start remains. */ export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { try { From ff5e47d5cff84194e457c05e4bfbe26a30ea69a8 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Fri, 28 Aug 2026 16:02:28 +0900 Subject: [PATCH 42/42] fix(workspace): fail closed on throwing runtime metadata --- apps/desktop/src/features/player/index.tsx | 14 +++++++++++--- .../src/features/workspace/firstIntro.ts | 19 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 83c1e992d..0698feadc 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -17,7 +17,11 @@ type PlayerFeatureProps = { function readOwnDataProperty(value: object, key: PropertyKey): unknown { try { const descriptor = Object.getOwnPropertyDescriptor(value, key); - return descriptor && "value" in descriptor ? descriptor.value : undefined; + if (!descriptor || !("value" in descriptor)) { + return undefined; + } + Reflect.get(value, key); + return descriptor.value; } catch { return undefined; } @@ -73,7 +77,11 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP } const sections = playerSummarySections(song); - const songTitle = typeof song.title === "string" ? song.title : ""; + const rawSongTitle = readOwnDataProperty(song, "title"); + const songTitle = typeof rawSongTitle === "string" ? rawSongTitle : ""; + const calloutSong = sections.length === 0 + ? ({ sections: [] } as unknown as RehearsalSong) + : song; const sectionCountLabel = t( sections.length === 1 ? "metricConfidenceSectionCountSingular" @@ -83,7 +91,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP return (

{title}

- +
candidate !== null); - const repeatedRoleIds = repeatedIds(roleCandidates.map((candidate) => candidate.id)); + const roleCandidates = roles.map(readRankedRoleCandidate); + if (roleCandidates.some((candidate) => candidate === null)) { + return []; + } + const validRoleCandidates = roleCandidates.filter( + (candidate): candidate is RankedRoleCandidate => candidate !== null + ); + const repeatedRoleIds = repeatedIds(validRoleCandidates.map((candidate) => candidate.id)); const graphCandidates = partGraph.flatMap((node) => { if (!isRuntimeObject(node)) { @@ -209,7 +216,7 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleCandidate[] { .map((candidate) => candidate.roleId) ); - return roleCandidates.filter( + return validRoleCandidates.filter( (candidate) => !repeatedRoleIds.has(candidate.id) && activeIds.has(candidate.id) ); }