From b2aa330719feb786c8f64ad573fedeea531cf1ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:16:06 +0000 Subject: [PATCH 01/32] feat(workspace): guide tonight's first tag on the rehearsal map Name the earliest labeled tag and the part that holds the last line so the room can catch the ending together. Open scrolls the matching rendered map section. Do not invent a tag from outro, verse, or an unlabeled last section. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstTagCallout.particle.test.tsx | 45 ++++ .../FirstTagCallout.reduced-motion.test.tsx | 70 ++++++ .../workspace/FirstTagCallout.test.tsx | 146 ++++++++++++ .../features/workspace/FirstTagCallout.tsx | 126 ++++++++++ .../src/features/workspace/Workspace.test.tsx | 44 ++++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstTag.inherited-metadata.test.ts | 50 ++++ .../src/features/workspace/firstTag.test.ts | 206 ++++++++++++++++ .../src/features/workspace/firstTag.ts | 220 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 +++- apps/desktop/src/i18n/index.ts | 34 +++ apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-tag-navigation.md | 14 ++ 19 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTagCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTagCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTag.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTag.ts create mode 100644 docs/doctoring/reduced-motion-first-tag-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..1df97afd1 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 tag with the holding part when an active role is corroborated, the labeled last line, 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..c8995410b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- The mounted workspace copy for tonight's first labeled tag must name the holding part when corroborated, the labeled last line, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a tag from outro, verse, chorus, or the last unlabeled section. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..e40dfe6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's first labeled tag in the mounted rehearsal workspace so the room can catch the last line together; the Open action moves to the matching rendered map section. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..33b686f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first labeled tag and opens the matching rendered map section. Do not invent a last line from outro, verse, or an unlabeled ending. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx new file mode 100644 index 000000000..ee9818130 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTagCallout } from "./FirstTagCallout"; + +describe("FirstTagCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the tag action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const tag = structuredClone(seed); + tag.id = "tag-particle"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + tag.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; + tag.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [tag]; + + const grid = document.createElement("div"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("3:20 태그에서 피아노 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "3:20 피아노 태그 위치 열기" })); + + expect(screen.getByText("3:20에서 피아노 파트와 함께 마지막 한 줄을 잡으세요. 같이 끝내세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..0c32469a3 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstTagCallout } from "./FirstTagCallout"; + +function songWithTag() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const tag = structuredClone(verse); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + tag.roles = [ + { + ...verse.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + tag.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, tag]; + return song; +} + +describe("FirstTagCallout 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"); + const first = document.createElement("div"); + first.dataset.sectionIndex = "0"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx new file mode 100644 index 000000000..5336cca10 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -0,0 +1,146 @@ +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 { FirstTagCallout } from "./FirstTagCallout"; + +function songWithTag() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const tag = structuredClone(verse); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + tag.roles = [ + { + ...verse.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + tag.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, tag]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.id = "song-structure-grid"; + const first = document.createElement("div"); + first.dataset.sectionIndex = "0"; + const unrelatedSibling = document.createElement("div"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(unrelatedSibling); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstTagCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.") + ).toBeTruthy(); + }); + + it("names the first tag as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal tag at 3:20" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End 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 Lead Vocal tag at 3:20" })); + + expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy(); + expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithTag(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first tag changes or returns later", () => { + const initialSong = songWithTag(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); + + const nextSong = songWithTag(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 220, end: 228 }; + rerender(); + expect(screen.getByText("Lead Vocal holds the tag at 3:40.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable tag guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide last line when no part holds the tag", () => { + const song = songWithTag(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first tag at 3:20" })).toBeTruthy(); + expect(screen.getByText("The band catches the tag at 3:20.")).toBeTruthy(); + }); + + it("localizes the tag form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithTag(); + song.sections[1]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("3:20 태그에서 리드 보컬 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy(); + expect(screen.queryByText(/tag에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx new file mode 100644 index 000000000..1b931484b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -0,0 +1,126 @@ +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 { formatTagTime, resolveFirstTag } from "./firstTag"; + +/** Props for the first-tag rehearsal callout. */ +export interface FirstTagCalloutProps { + song: RehearsalSong; +} + +type TagCopyValues = Readonly>; + +type OpenedTag = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate tag placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatTagCopy(template: string, values: TagCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof TagCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredTagScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled tag and open the matching rendered map section. */ +export function FirstTagCallout({ song }: FirstTagCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const tag = resolveFirstTag(song); + const tagSectionIndex = + tag && Array.isArray(runtimeSong?.sections) ? runtimeSong.sections.indexOf(tag.section) : -1; + const [openedTag, setOpenedTag] = useState(null); + + useEffect(() => { + setOpenedTag(null); + }, [songId, tagSectionIndex, tag?.section.id, tag?.holdingRole?.id, tag?.atSeconds]); + + if (!tag) { + return ( + + ); + } + + const opened = + openedTag?.songId === songId && + openedTag.sectionId === tag.section.id && + openedTag.sectionIndex === tagSectionIndex && + openedTag.holdingRoleId === (tag.holdingRole?.id ?? null) && + openedTag.atSeconds === tag.atSeconds; + const at = formatTagTime(tag.atSeconds); + const copyValues: TagCopyValues = { + role: tag.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, tag.section.label), + at + }; + const hasRole = tag.holdingRole !== null; + const actionLabel = formatTagCopy( + t(hasRole ? "firstTagOpenAction" : "firstTagOpenActionBand"), + copyValues + ); + const body = formatTagCopy(t(hasRole ? "firstTagBody" : "firstTagBodyBand"), copyValues); + const armed = formatTagCopy(t(hasRole ? "firstTagArmed" : "firstTagArmedBand"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..74632d202 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 tag as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const tag = structuredClone(verse); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + tag.roles = [ + { + ...verse.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + tag.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, tag]; + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(1); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal tag at 3:20" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..d9116c239 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 { FirstTagCallout } from "./FirstTagCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

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

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts new file mode 100644 index 000000000..d890f60cc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts @@ -0,0 +1,50 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTag } from "./firstTag"; + +function songWithTag() { + const song = createDemoRehearsalSong(); + const tag = structuredClone(song.sections[0]!); + tag.id = "tag-own"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + song.sections = [tag]; + return { song, tag }; +} + +describe("resolveFirstTag inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, tag } = songWithTag(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstTag(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(tag) as typeof tag; + song.sections = [inheritedSection]; + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, tag } = songWithTag(); + tag.timeRange = Object.create({ start: 200, end: 208 }) as typeof tag.timeRange; + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, tag } = songWithTag(); + const role = tag.roles[0]!; + const node = tag.partGraph[0]!; + tag.roles = [Object.create(role) as typeof role]; + tag.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstTag(song); + expect(resolved?.section.id).toBe("tag-own"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, tag } = songWithTag(); + const arraySection = Object.assign([], tag) as unknown as typeof tag; + song.sections = [arraySection]; + expect(resolveFirstTag(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTag.test.ts b/apps/desktop/src/features/workspace/firstTag.test.ts new file mode 100644 index 000000000..d3d3c643c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTag.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatTagTime, resolveFirstTag } from "./firstTag"; + +function withTagSection( + 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 tag = structuredClone(verse); + tag.id = overrides.id ?? "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: overrides.start ?? 200, end: overrides.end ?? 208 }; + const roleId = overrides.roleId ?? "lead-vocal"; + tag.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + tag.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, tag]; + return song; +} + +describe("resolveFirstTag", () => { + it("returns null when the demo song has no labeled tag", () => { + expect(resolveFirstTag(createDemoRehearsalSong())).toBeNull(); + expect(formatTagTime(Number.NaN)).toBe("0:00"); + expect(formatTagTime(-4)).toBe("0:00"); + }); + + it("does not invent a tag from a verse, chorus, intro, outro, 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 intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + 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 = [intro, verse, pickup, stop, chorus, handoff, outro]; + + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("does not treat the last unlabeled section as a tag", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { start: 200, end: 208 }; + expect(song.sections[0]!.label).toBe("verse"); + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("picks the earliest labeled tag and the part that holds it", () => { + const song = withTagSection({ start: 200, end: 208 }); + const tag = resolveFirstTag(song); + + expect(tag?.section.id).toBe("tag-1"); + expect(tag?.holdingRole?.id).toBe("lead-vocal"); + expect(tag?.atSeconds).toBe(200); + expect(formatTagTime(tag?.atSeconds ?? -1)).toBe("3:20"); + }); + + it("prefers the earlier of two labeled tags", () => { + const song = withTagSection({ id: "tag-late", start: 220, end: 228 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "tag-early"; + earlier.timeRange = { start: 200, end: 208 }; + 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[0]!, song.sections[1]!, earlier]; + + const tag = resolveFirstTag(song); + expect(tag?.section.id).toBe("tag-early"); + expect(tag?.holdingRole?.id).toBe("bass-guitar"); + expect(tag?.atSeconds).toBe(200); + }); + + it("breaks same-time tag ties with locale-independent id ordering", () => { + const song = withTagSection({ id: "ä-tag", start: 200, end: 208 }); + const asciiTag = structuredClone(song.sections[1]!); + asciiTag.id = "z-tag"; + song.sections = [song.sections[0]!, song.sections[1]!, asciiTag]; + + expect(resolveFirstTag(song)?.section.id).toBe("z-tag"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withTagSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const tag = song.sections[1]!; + const asciiRole = { ...tag.roles[0]!, id: "z-role", name: "ASCII role" }; + tag.roles = [tag.roles[0]!, asciiRole]; + tag.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstTag(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide last line when no active ranked role holds it", () => { + const song = withTagSection({ isActive: false }); + const tag = resolveFirstTag(song); + expect(tag?.section.id).toBe("tag-1"); + expect(tag?.holdingRole).toBeNull(); + expect(tag?.atSeconds).toBe(200); + }); + + it("skips a tag whose rehearsal window is unbounded", () => { + const song = withTagSection({ start: Number.NaN, end: 208 }); + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("skips a tag whose end precedes its start", () => { + const song = withTagSection({ start: 208, end: 200 }); + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("skips a zero-length tag window", () => { + const song = withTagSection({ start: 200, end: 200 }); + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("skips a tag whose endpoint overflows the shared timing bound", () => { + const song = withTagSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }); + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstTag(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withTagSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstTag(song)).toBeNull(); + }); + + it("keeps the last line band-wide when role identities are duplicated", () => { + const song = withTagSection(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const tag = resolveFirstTag(song); + expect(tag?.section.id).toBe("tag-1"); + expect(tag?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTag.ts b/apps/desktop/src/features/workspace/firstTag.ts new file mode 100644 index 000000000..312cefd37 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTag.ts @@ -0,0 +1,220 @@ +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 tag: the earliest last-line ending and the part that holds it. */ +export type FirstTag = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative tag time as m:ss for rehearsal copy. */ +export function formatTagTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns 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; + } + 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)) { + return false; + } + } + return true; +} + +/** 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 { + if (!hasOwn(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwn(timeRange, "start") || !hasOwn(timeRange, "end")) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the highest-priority ranked role, then a locale-independent stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwn(section, "roles") || + !hasOwn(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.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 activeIds = new Set( + section.partGraph + .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) + ) + .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 tag, or null when no safe last-line ending remains. */ +export function resolveFirstTag(song: RehearsalSong): FirstTag | null { + if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const tagSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwn(section, "label") && + section.label === "tag" && + 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 = tagSections[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..b00a18761 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported section form label for Korean rehearsal copy", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "tag")).toBe("tag"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-tag next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstTagOpenAction")).toBe("{at} {role} 태그 위치 열기"); + expect(t("firstTagBody")).toBe("{at} {section}에서 {role} 파트가 마지막 한 줄을 잡습니다."); + expect(t("firstTagArmed")).toBe("{at}에서 {role} 파트와 함께 마지막 한 줄을 잡으세요. 같이 끝내세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..dbbabe295 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized buyer copy for an own supported section-form entry. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..0c8fae9f8 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstTagLabel": "Tonight's first tag", + "firstTagOpenAction": "Open {role} tag at {at}", + "firstTagOpenActionBand": "Open the first tag at {at}", + "firstTagBody": "{role} holds the {section} at {at}.", + "firstTagBodyBand": "The band catches the {section} at {at}.", + "firstTagArmed": "Catch the last line with {role} at {at}. End together.", + "firstTagArmedBand": "Catch the last line at {at}. End together.", + "firstTagUnavailable": "No tag yet. Stay on tonight's map until the last line is labeled." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..cb1e56f77 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstTagLabel": "오늘 첫 태그", + "firstTagOpenAction": "{at} {role} 태그 위치 열기", + "firstTagOpenActionBand": "{at} 첫 태그 위치 열기", + "firstTagBody": "{at} {section}에서 {role} 파트가 마지막 한 줄을 잡습니다.", + "firstTagBodyBand": "밴드가 {at} {section}에서 마지막 한 줄을 잡습니다.", + "firstTagArmed": "{at}에서 {role} 파트와 함께 마지막 한 줄을 잡으세요. 같이 끝내세요.", + "firstTagArmedBand": "{at}에서 마지막 한 줄을 잡으세요. 같이 끝내세요.", + "firstTagUnavailable": "아직 태그가 없습니다. 마지막 한 줄이 표시될 때까지 오늘 지도에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..b9a2e81c9 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 Tag Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstTagCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `tag` start, and the time. Do not invent a last line from `intro`, `verse`, `chorus`, `outro`, `pickup`, `stop`, `handoff`, or the last unlabeled section. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-outro, first-chorus, first-stop, and first-handoff work. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-tag-navigation.md b/docs/doctoring/reduced-motion-first-tag-navigation.md new file mode 100644 index 000000000..f0da4b1c0 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-tag-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-tag navigation + +Workspace map navigation for tonight's first tag follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstTagCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Tag resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- 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: tag 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 d17a93b8d1c197c9555a5ded2a509df59228f972 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:19:18 -0700 Subject: [PATCH 02/32] test(workspace): reject throwing tag metadata accessors --- .../workspace/firstTag.inherited-metadata.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts index d890f60cc..717fbe97a 100644 --- a/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts @@ -29,6 +29,20 @@ describe("resolveFirstTag inherited metadata", () => { expect(resolveFirstTag(song)).toBeNull(); }); + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, tag } = songWithTag(); + Object.defineProperty(tag, "label", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile tag label getter"); + } + }); + + expect(() => resolveFirstTag(song)).not.toThrow(); + expect(resolveFirstTag(song)).toBeNull(); + }); + it("does not let inherited role or graph metadata establish the holding part", () => { const { song, tag } = songWithTag(); const role = tag.roles[0]!; From 30552b6e8bc13a96ea734418ed2440fafa67ad3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:27:41 -0700 Subject: [PATCH 03/32] test(workspace): reject cross-surface tag target --- .../workspace/FirstTagCallout.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index 5336cca10..692fa14d8 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -101,6 +101,28 @@ describe("FirstTagCallout", () => { grid.remove(); }); + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "1"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + it("shows fresh guidance when the first tag changes or returns later", () => { const initialSong = songWithTag(); const { grid } = appendSongStructureTarget(); From e731168713388af7fbd24cf4da4bec44696a68e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:28:04 -0700 Subject: [PATCH 04/32] fix(workspace): contain hostile tag metadata accessors --- apps/desktop/src/features/workspace/firstTag.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTag.ts b/apps/desktop/src/features/workspace/firstTag.ts index 312cefd37..51efde0a6 100644 --- a/apps/desktop/src/features/workspace/firstTag.ts +++ b/apps/desktop/src/features/workspace/firstTag.ts @@ -183,8 +183,8 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { ); } -/** Return the first labeled tag, or null when no safe last-line ending remains. */ -export function resolveFirstTag(song: RehearsalSong): FirstTag | null { +/** Resolve a tag after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstTag(song: RehearsalSong): FirstTag | null { if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { return null; } @@ -218,3 +218,12 @@ export function resolveFirstTag(song: RehearsalSong): FirstTag | null { atSeconds: section.timeRange.start }; } + +/** Return the first labeled tag, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstTag(song: RehearsalSong): FirstTag | null { + try { + return resolveSafeFirstTag(song); + } catch { + return null; + } +} From bad3e4eec240326a7559a00384d4b95b3bd5dad4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:28:25 -0700 Subject: [PATCH 05/32] fix(workspace): scope first-tag navigation to map renderer --- apps/desktop/src/features/workspace/FirstTagCallout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 1b931484b..d08c3f120 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -99,9 +99,12 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { type="button" className="mt-3 min-h-11 bg-gradient-to-r from-amber-300 to-rose-300 font-black text-slate-950" onClick={() => { + const renderer = document.getElementById("song-structure-grid"); const target = tagSectionIndex >= 0 - ? document.querySelector(`[data-section-index="${tagSectionIndex}"]`) + ? (renderer?.querySelector( + `[data-section-index="${tagSectionIndex}"]` + ) ?? null) : null; if (typeof target?.scrollIntoView !== "function") { return; From d2d71b38acf8235187f7626271093155ff1974c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:31:44 -0700 Subject: [PATCH 06/32] fix(workspace): anchor first-tag navigation to structure grid --- .../src/features/workspace/Workspace.tsx | 196 +++++++----------- 1 file changed, 76 insertions(+), 120 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d9116c239..f1213815f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -87,6 +87,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R aria-label="Scrollable song structure timeline" >
(Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), - [song.collaboration] - ); - const collaborationComments = useMemo( - () => (Array.isArray(song.collaboration?.comments) ? song.collaboration.comments : []), - [song.collaboration] - ); - const collaborationApprovals = useMemo( - () => (Array.isArray(song.collaboration?.approvals) ? song.collaboration.approvals : []), - [song.collaboration] - ); - const collaborationSummary = useMemo( - () => ({ - assignments: collaborationAssignments.length, - comments: collaborationComments.length, - approvals: collaborationApprovals.length - }), - [collaborationApprovals.length, collaborationAssignments.length, collaborationComments.length] - ); - const activeRoleAssignments = useMemo( - () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === activeRole), - [activeRole, collaborationAssignments] - ); - const activeRoleComments = useMemo( - () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === activeRole), - [activeRole, collaborationComments] + + const safeSourceBootstrap = useMemo( + () => safeProjectBootstrapSummary(sourceBootstrap), + [sourceBootstrap] ); - const roleHarmonicExplanation = - nonBlankText(activeRoleDetails?.harmonicExplanation) ?? - nonBlankText(activeRoleDetails?.harmony.functionLabel) ?? - t("workspaceHarmonyExplainFallback"); - const roleTranspositionPlan = - nonBlankText(activeRoleDetails?.transpositionPlan) ?? - nonBlankText(activeRoleDetails?.simplification); - - /** Documented. */ + + const collaborationSummary = useMemo(() => { + if (!song.collaboration) { + return { assignments: 0, comments: 0, approvals: 0 }; + } + + return { + assignments: song.collaboration.assignments.length, + comments: song.collaboration.comments.length, + approvals: song.collaboration.approvals.length + }; + }, [song.collaboration]); + + const activeRolePlan = useMemo(() => { + if (!activeRole || !song.rehearsalPlan) { + return null; + } + return song.rehearsalPlan.rolePlans.find((plan) => plan.roleId === activeRole) ?? null; + }, [activeRole, song.rehearsalPlan]); + + const roleHarmonicExplanation = nonBlankText(activeRolePlan?.harmonicExplanation); + const roleTransposition = nonBlankText(activeRolePlan?.transposition); + const roleTranspositionRationale = nonBlankText(activeRolePlan?.transpositionRationale); + const handleExportCueSheet = () => { - const csv = generateCueSheetCsv(song); - downloadTextFile(csv, "text/csv;charset=utf-8;", `${sanitizeFilename(song.title)}_cuesheet.csv`); + downloadTextFile(generateCueSheetCsv(song), "text/csv", sanitizeFilename(`${song.title}-cue-sheet.csv`)); }; - /** Documented. */ const handleExportChart = () => { - const json = generateChartSummaryJson(song); - downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_chart.json`); + downloadTextFile( + generateChartSummaryJson(song), + "application/json", + sanitizeFilename(`${song.title}-chart.json`) + ); }; - /** Documented. */ const handleExportHandoff = () => { - const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap); - const json = generateMetadataHandoffJson(song, { - sourceBootstrap: parsedSourceBootstrap, - workspaceId: song.id, - workspaceTitle: song.title - }); - downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_handoff.json`); + downloadTextFile( + generateMetadataHandoffJson(song, safeSourceBootstrap), + "application/json", + sanitizeFilename(`${song.title}-handoff.json`) + ); }; return ( -
- - -
-
-
-

{t("workspaceRehearsalMapLabel")}

- {song.tempo && ( - - {t("workspaceTempoLabel")}: {song.tempo} BPM - - )} -
-

{song.title}

- +
+ + +
+
+
+ {t("workspaceSurfaceLabel")} + {t("workspaceLocalBadge")} +
+ {song.exportSummary?.headline || t("workspaceRehearsalFallback")}
@@ -428,71 +414,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
-

- {roleTranspositionPlan} +

+ {roleTransposition ?? t("workspaceTranspositionUnavailable")} +

+

+ {roleTranspositionRationale ?? t("workspaceTranspositionRationaleFallback")}

- {song.collaboration && ( -
-
-
-
-
- {activeRoleAssignments.map((assignment) => ( -
-

{assignment.assignee}

-

{assignment.summary}

-

{formatStatusLabel(assignment.status)}

-
- ))} -
-
-
-
-
-
- {activeRoleComments.map((comment) => ( -
-

{comment.author}

-

{comment.body}

-

{formatStatusLabel(comment.status)}

-
- ))} -
-
-
-
-
-
- {collaborationApprovals.map((approval) => ( -
-

{approval.scope}

-

{approval.owner}

-

{formatStatusLabel(approval.status)}

-
- ))} -
-
-
- )} - -
)} - +
+

+

+ +
+ +
+

+

+ +
+
+
+ + -
From dcc13db7f90f5d92d4652b610907a0a33e8bf55d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:31:58 -0700 Subject: [PATCH 07/32] test(workspace): model scoped tag renderer in reduced-motion fixture --- .../features/workspace/FirstTagCallout.reduced-motion.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx index 0c32469a3..15e24dd20 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx @@ -48,6 +48,7 @@ describe("FirstTagCallout reduced motion", () => { })); const grid = document.createElement("div"); + grid.id = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const target = document.createElement("div"); From 22acea8d1e4ece03b6fd8a335345a9befe9ae536 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:32:16 -0700 Subject: [PATCH 08/32] test(workspace): model scoped tag renderer in particle fixture --- .../src/features/workspace/FirstTagCallout.particle.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx index ee9818130..cb054aeb0 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx @@ -21,6 +21,7 @@ describe("FirstTagCallout Korean role copy", () => { song.sections = [tag]; const grid = document.createElement("div"); + grid.id = "song-structure-grid"; const target = document.createElement("div"); target.dataset.sectionIndex = "0"; Object.defineProperty(target, "scrollIntoView", { From 7d3ffa9c4d82a7196f7b9d4fbbc1104c6d358496 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:35:04 -0700 Subject: [PATCH 09/32] fix(workspace): restore canonical workspace surface --- .../src/features/workspace/Workspace.tsx | 196 +++++++++++------- 1 file changed, 120 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index f1213815f..d9116c239 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -87,7 +87,6 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R aria-label="Scrollable song structure timeline" >
safeProjectBootstrapSummary(sourceBootstrap), - [sourceBootstrap] + const collaborationAssignments = useMemo( + () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), + [song.collaboration] ); - - const collaborationSummary = useMemo(() => { - if (!song.collaboration) { - return { assignments: 0, comments: 0, approvals: 0 }; - } - - return { - assignments: song.collaboration.assignments.length, - comments: song.collaboration.comments.length, - approvals: song.collaboration.approvals.length - }; - }, [song.collaboration]); - - const activeRolePlan = useMemo(() => { - if (!activeRole || !song.rehearsalPlan) { - return null; - } - return song.rehearsalPlan.rolePlans.find((plan) => plan.roleId === activeRole) ?? null; - }, [activeRole, song.rehearsalPlan]); - - const roleHarmonicExplanation = nonBlankText(activeRolePlan?.harmonicExplanation); - const roleTransposition = nonBlankText(activeRolePlan?.transposition); - const roleTranspositionRationale = nonBlankText(activeRolePlan?.transpositionRationale); - + const collaborationComments = useMemo( + () => (Array.isArray(song.collaboration?.comments) ? song.collaboration.comments : []), + [song.collaboration] + ); + const collaborationApprovals = useMemo( + () => (Array.isArray(song.collaboration?.approvals) ? song.collaboration.approvals : []), + [song.collaboration] + ); + const collaborationSummary = useMemo( + () => ({ + assignments: collaborationAssignments.length, + comments: collaborationComments.length, + approvals: collaborationApprovals.length + }), + [collaborationApprovals.length, collaborationAssignments.length, collaborationComments.length] + ); + const activeRoleAssignments = useMemo( + () => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === activeRole), + [activeRole, collaborationAssignments] + ); + const activeRoleComments = useMemo( + () => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === activeRole), + [activeRole, collaborationComments] + ); + const roleHarmonicExplanation = + nonBlankText(activeRoleDetails?.harmonicExplanation) ?? + nonBlankText(activeRoleDetails?.harmony.functionLabel) ?? + t("workspaceHarmonyExplainFallback"); + const roleTranspositionPlan = + nonBlankText(activeRoleDetails?.transpositionPlan) ?? + nonBlankText(activeRoleDetails?.simplification); + + /** Documented. */ const handleExportCueSheet = () => { - downloadTextFile(generateCueSheetCsv(song), "text/csv", sanitizeFilename(`${song.title}-cue-sheet.csv`)); + const csv = generateCueSheetCsv(song); + downloadTextFile(csv, "text/csv;charset=utf-8;", `${sanitizeFilename(song.title)}_cuesheet.csv`); }; + /** Documented. */ const handleExportChart = () => { - downloadTextFile( - generateChartSummaryJson(song), - "application/json", - sanitizeFilename(`${song.title}-chart.json`) - ); + const json = generateChartSummaryJson(song); + downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_chart.json`); }; + /** Documented. */ const handleExportHandoff = () => { - downloadTextFile( - generateMetadataHandoffJson(song, safeSourceBootstrap), - "application/json", - sanitizeFilename(`${song.title}-handoff.json`) - ); + const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap); + const json = generateMetadataHandoffJson(song, { + sourceBootstrap: parsedSourceBootstrap, + workspaceId: song.id, + workspaceTitle: song.title + }); + downloadTextFile(json, "application/json;charset=utf-8;", `${sanitizeFilename(song.title)}_handoff.json`); }; return ( -
- - -
-
-
- {t("workspaceSurfaceLabel")} - {t("workspaceLocalBadge")} -
- +
+ + +
+
+
+

{t("workspaceRehearsalMapLabel")}

+ {song.tempo && ( + + {t("workspaceTempoLabel")}: {song.tempo} BPM + + )} +
+

{song.title}

+ {song.exportSummary?.headline || t("workspaceRehearsalFallback")}
@@ -414,41 +428,71 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
-

- {roleTransposition ?? t("workspaceTranspositionUnavailable")} -

-

- {roleTranspositionRationale ?? t("workspaceTranspositionRationaleFallback")} +

+ {roleTranspositionPlan}

+ {song.collaboration && ( +
+
+
+
+
+ {activeRoleAssignments.map((assignment) => ( +
+

{assignment.assignee}

+

{assignment.summary}

+

{formatStatusLabel(assignment.status)}

+
+ ))} +
+
+
+
+
+
+ {activeRoleComments.map((comment) => ( +
+

{comment.author}

+

{comment.body}

+

{formatStatusLabel(comment.status)}

+
+ ))} +
+
+
+
+
+
+ {collaborationApprovals.map((approval) => ( +
+

{approval.scope}

+

{approval.owner}

+

{formatStatusLabel(approval.status)}

+
+ ))} +
+
+
+ )} + +
)} -
-
-

-

- -
- -
-

-

- -
-
- - - +
From 93f6ae3facbf6577cd1bff7ebdf454138ff4c401 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:35:54 -0700 Subject: [PATCH 10/32] fix(workspace): bind first-tag navigation to rendered structure root --- apps/desktop/src/features/workspace/FirstTagCallout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index d08c3f120..8dc45f2f2 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -99,7 +99,9 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { type="button" className="mt-3 min-h-11 bg-gradient-to-r from-amber-300 to-rose-300 font-black text-slate-950" onClick={() => { - const renderer = document.getElementById("song-structure-grid"); + const renderer = document.querySelector( + '[data-testid="song-structure-grid"]' + ); const target = tagSectionIndex >= 0 ? (renderer?.querySelector( From 7dbdd01f74724764ac0fa0a2f30493cc783d2896 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:36:13 -0700 Subject: [PATCH 11/32] test(workspace): scope first-tag fixture to rendered structure root --- apps/desktop/src/features/workspace/FirstTagCallout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index 692fa14d8..c6cf2d85c 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -32,7 +32,7 @@ function songWithTag() { function appendSongStructureTarget() { const grid = document.createElement("div"); - grid.id = "song-structure-grid"; + grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const unrelatedSibling = document.createElement("div"); From 36c19621c75dbe7ffa65ff7367b2ddbcc1520d30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:36:24 -0700 Subject: [PATCH 12/32] test(workspace): mirror rendered structure root for reduced motion --- .../features/workspace/FirstTagCallout.reduced-motion.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx index 15e24dd20..d05afab49 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx @@ -48,7 +48,7 @@ describe("FirstTagCallout reduced motion", () => { })); const grid = document.createElement("div"); - grid.id = "song-structure-grid"; + grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const target = document.createElement("div"); From 9a96a025bf48e66543addcc50be5f27e5b960d9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:36:40 -0700 Subject: [PATCH 13/32] test(workspace): mirror rendered structure root for Korean copy --- .../src/features/workspace/FirstTagCallout.particle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx index cb054aeb0..62eea8d77 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx @@ -21,7 +21,7 @@ describe("FirstTagCallout Korean role copy", () => { song.sections = [tag]; const grid = document.createElement("div"); - grid.id = "song-structure-grid"; + grid.dataset.testid = "song-structure-grid"; const target = document.createElement("div"); target.dataset.sectionIndex = "0"; Object.defineProperty(target, "scrollIntoView", { From 047b3d90cee1460bbc5848faa0558e0aab8f00a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:39:11 -0700 Subject: [PATCH 14/32] docs(i18n): describe exported translation helpers --- 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 dbbabe295..352eff65e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -39,14 +39,14 @@ const sectionFormLabels: Readonly>; return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); From 523ac838902108a35573936de2fb0a63dab22443 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:43:47 -0700 Subject: [PATCH 15/32] test(workspace): reject accessor-backed tag identity --- .../workspace/firstTag.inherited-metadata.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts index 717fbe97a..8da6b41af 100644 --- a/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstTag.inherited-metadata.test.ts @@ -43,6 +43,19 @@ describe("resolveFirstTag inherited metadata", () => { expect(resolveFirstTag(song)).toBeNull(); }); + it("does not treat own accessors as stable tag identity authority", () => { + const { song, tag } = songWithTag(); + Object.defineProperty(tag, "id", { + configurable: true, + enumerable: true, + get() { + return "tag-own"; + } + }); + + expect(resolveFirstTag(song)).toBeNull(); + }); + it("does not let inherited role or graph metadata establish the holding part", () => { const { song, tag } = songWithTag(); const role = tag.roles[0]!; From 9ac1ad57ab5e307cef8706e1ead86e426a133561 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:44:27 -0700 Subject: [PATCH 16/32] fix(workspace): require data-backed tag metadata --- .../src/features/workspace/firstTag.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTag.ts b/apps/desktop/src/features/workspace/firstTag.ts index 51efde0a6..ccc59d89b 100644 --- a/apps/desktop/src/features/workspace/firstTag.ts +++ b/apps/desktop/src/features/workspace/firstTag.ts @@ -40,12 +40,13 @@ 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. */ -function hasOwn(value: object, key: PropertyKey): boolean { - return Object.prototype.hasOwnProperty.call(value, key); +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); } -/** Return whether every numeric index is an own element in a bounded runtime array. */ +/** Return whether every numeric index is an own data element in a bounded runtime array. */ function isDenseRuntimeArray(value: unknown): value is unknown[] { if (!Array.isArray(value)) { return false; @@ -55,7 +56,7 @@ function isDenseRuntimeArray(value: unknown): value is unknown[] { return false; } for (let index = 0; index < length; index += 1) { - if (!hasOwn(value, index)) { + if (!hasOwnData(value, index)) { return false; } } @@ -65,24 +66,28 @@ 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") && + hasOwnData(role, "id") && typeof role.id === "string" && role.id.trim().length > 0 && - hasOwn(role, "name") && + hasOwnData(role, "name") && typeof role.name === "string" && role.name.trim().length > 0 && - hasOwn(role, "rehearsalPriority") && + hasOwnData(role, "rehearsalPriority") && Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) ); } /** Return whether a section owns a bounded, positive-length integer rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { - if (!hasOwn(section, "timeRange")) { + if (!hasOwnData(section, "timeRange")) { return false; } const timeRange = section.timeRange as Partial | null; - if (!isRuntimeObject(timeRange) || !hasOwn(timeRange, "start") || !hasOwn(timeRange, "end")) { + if ( + !isRuntimeObject(timeRange) || + !hasOwnData(timeRange, "start") || + !hasOwnData(timeRange, "end") + ) { return false; } @@ -131,8 +136,8 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { /** Return ranked roles whose unique graph node is explicitly active. */ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { if ( - !hasOwn(section, "roles") || - !hasOwn(section, "partGraph") || + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || !isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph) ) { @@ -143,7 +148,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { .filter( (role) => isRuntimeObject(role) && - hasOwn(role, "id") && + hasOwnData(role, "id") && typeof role.id === "string" && role.id.trim().length > 0 ) @@ -152,7 +157,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { .filter( (node) => isRuntimeObject(node) && - hasOwn(node, "role_id") && + hasOwnData(node, "role_id") && typeof node.role_id === "string" && node.role_id.trim().length > 0 ) @@ -164,9 +169,9 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { .filter( (node) => isRuntimeObject(node) && - hasOwn(node, "is_active") && + hasOwnData(node, "is_active") && node.is_active === true && - hasOwn(node, "role_id") && + hasOwnData(node, "role_id") && typeof node.role_id === "string" && node.role_id.trim().length > 0 && !repeatedGraphRoleIds.has(node.role_id) @@ -185,7 +190,11 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Resolve a tag after the runtime root has passed its structural boundary checks. */ function resolveSafeFirstTag(song: RehearsalSong): FirstTag | null { - if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + if ( + !isRuntimeObject(song) || + !hasOwnData(song, "sections") || + !isDenseRuntimeArray(song.sections) + ) { return null; } @@ -193,9 +202,9 @@ function resolveSafeFirstTag(song: RehearsalSong): FirstTag | null { .filter( (section) => isRuntimeObject(section) && - hasOwn(section, "label") && + hasOwnData(section, "label") && section.label === "tag" && - hasOwn(section, "id") && + hasOwnData(section, "id") && typeof section.id === "string" && section.id.trim().length > 0 && hasBoundedTimeRange(section) From 4e73ea3e38eb47f4c056fbbc18b2f706401dc907 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:44:59 -0700 Subject: [PATCH 17/32] docs(changelog): record first-tag metadata hardening --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e40dfe6f4..c9f4bf85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. -- Name tonight's first labeled tag in the mounted rehearsal workspace so the room can catch the last line together; the Open action moves to the matching rendered map section. +- Name tonight's first labeled tag in the mounted rehearsal workspace so the room can catch the last line together; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 @@ -50,8 +50,7 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #33: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI From 47c45417ae8421bd92b4912eccf2ebfe9ee257c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:45:42 -0700 Subject: [PATCH 18/32] test(workspace): contain hostile song identity accessor --- .../features/workspace/FirstTagCallout.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index c6cf2d85c..5247f1532 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -63,6 +63,20 @@ describe("FirstTagCallout", () => { ).toBeTruthy(); }); + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithTag(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })).toBeTruthy(); + }); + it("names the first tag as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); From 041f7022b6d2b79fe5195f765ad495d131d9501b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:46:09 -0700 Subject: [PATCH 19/32] fix(workspace): contain hostile song identity metadata --- .../src/features/workspace/FirstTagCallout.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 8dc45f2f2..2e17d6119 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -31,6 +31,21 @@ function formatTagCopy(template: string, values: TagCopyValues): string { }); } +/** Read a stable own string without invoking accessor-backed runtime metadata. */ +function readOwnDataString(value: unknown, key: PropertyKey): string { + if (value === null || typeof value !== "object") { + return ""; + } + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor && typeof descriptor.value === "string" + ? descriptor.value + : ""; + } catch { + return ""; + } +} + /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredTagScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -44,7 +59,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); const runtimeSong = song as unknown as Partial | null; - const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const songId = readOwnDataString(runtimeSong, "id"); const tag = resolveFirstTag(song); const tagSectionIndex = tag && Array.isArray(runtimeSong?.sections) ? runtimeSong.sections.indexOf(tag.section) : -1; From 98b860558dd97deed5853194a964700b91bdd566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:08:19 -0700 Subject: [PATCH 20/32] test(coverage): include first-tag production files --- apps/desktop/vite.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..e3a192297 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstTag.ts", + "src/features/workspace/FirstTagCallout.tsx" ], thresholds: { lines: 90, From 8dc1528a39d0381c8e0988c6525954256ea5d328 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:08:35 -0700 Subject: [PATCH 21/32] docs(changelog): restore released 0.1.0 history --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f4bf85f..9a2d63aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,8 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Engineered section, form, and cue anchor extraction pipeline +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI From 40556d11f92c88b1dddc46489f717725f1e8afa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:09:36 -0700 Subject: [PATCH 22/32] test(workspace): require stable first-tag renderer hook --- apps/desktop/src/features/workspace/FirstTagCallout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index 5247f1532..da2967639 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -32,7 +32,7 @@ function songWithTag() { function appendSongStructureTarget() { const grid = document.createElement("div"); - grid.dataset.testid = "song-structure-grid"; + grid.id = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const unrelatedSibling = document.createElement("div"); From 978b5f833ce3252b84320a596ed3a6a6cd9ce058 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:10:21 -0700 Subject: [PATCH 23/32] test(workspace): target first-tag navigation by semantic region --- .../src/features/workspace/FirstTagCallout.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index da2967639..fc1ef26d2 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -31,8 +31,10 @@ function songWithTag() { } function appendSongStructureTarget() { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", "Scrollable song structure timeline"); const grid = document.createElement("div"); - grid.id = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const unrelatedSibling = document.createElement("div"); @@ -46,8 +48,9 @@ function appendSongStructureTarget() { grid.appendChild(first); grid.appendChild(unrelatedSibling); grid.appendChild(target); - document.body.appendChild(grid); - return { grid, scrollIntoView }; + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; } describe("FirstTagCallout", () => { From d342f7bc80e842c82bfa0f3f75f4d0aacb48f186 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:10:40 -0700 Subject: [PATCH 24/32] fix(workspace): navigate first tag through semantic timeline --- apps/desktop/src/features/workspace/FirstTagCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 2e17d6119..6b34457c3 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -115,7 +115,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { className="mt-3 min-h-11 bg-gradient-to-r from-amber-300 to-rose-300 font-black text-slate-950" onClick={() => { const renderer = document.querySelector( - '[data-testid="song-structure-grid"]' + '[role="region"][aria-label="Scrollable song structure timeline"]' ); const target = tagSectionIndex >= 0 From afef071fe1951d071dbfc98cf3adac5bf3a2fbd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:18:06 -0700 Subject: [PATCH 25/32] test(workspace): reset first-tag state across hostile song ids --- .../workspace/FirstTagCallout.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index fc1ef26d2..8f2af4697 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -80,6 +80,32 @@ describe("FirstTagCallout", () => { expect(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })).toBeTruthy(); }); + it("resets armed guidance when accessor-id songs change with the same tag signature", () => { + const firstSong = songWithTag(); + const nextSong = songWithTag(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy(); + expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull(); + + grid.remove(); + }); + it("names the first tag as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); From ce69ca98b8434886007a745c4038f82224270f73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:19:38 -0700 Subject: [PATCH 26/32] fix(workspace): key first-tag state by song instance --- .../features/workspace/FirstTagCallout.tsx | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 6b34457c3..59792ba0b 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -16,7 +16,7 @@ export interface FirstTagCalloutProps { type TagCopyValues = Readonly>; type OpenedTag = Readonly<{ - songId: string; + songIdentity: unknown; sectionId: string; sectionIndex: number; holdingRoleId: string | null; @@ -31,21 +31,6 @@ function formatTagCopy(template: string, values: TagCopyValues): string { }); } -/** Read a stable own string without invoking accessor-backed runtime metadata. */ -function readOwnDataString(value: unknown, key: PropertyKey): string { - if (value === null || typeof value !== "object") { - return ""; - } - try { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - return descriptor && "value" in descriptor && typeof descriptor.value === "string" - ? descriptor.value - : ""; - } catch { - return ""; - } -} - /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredTagScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -58,8 +43,8 @@ function preferredTagScrollBehavior(): ScrollBehavior { export function FirstTagCallout({ song }: FirstTagCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); + const songIdentity: unknown = song; const runtimeSong = song as unknown as Partial | null; - const songId = readOwnDataString(runtimeSong, "id"); const tag = resolveFirstTag(song); const tagSectionIndex = tag && Array.isArray(runtimeSong?.sections) ? runtimeSong.sections.indexOf(tag.section) : -1; @@ -67,7 +52,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { useEffect(() => { setOpenedTag(null); - }, [songId, tagSectionIndex, tag?.section.id, tag?.holdingRole?.id, tag?.atSeconds]); + }, [songIdentity, tagSectionIndex, tag?.section.id, tag?.holdingRole?.id, tag?.atSeconds]); if (!tag) { return ( @@ -83,7 +68,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { } const opened = - openedTag?.songId === songId && + openedTag?.songIdentity === songIdentity && openedTag.sectionId === tag.section.id && openedTag.sectionIndex === tagSectionIndex && openedTag.holdingRoleId === (tag.holdingRole?.id ?? null) && @@ -131,7 +116,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { behavior: preferredTagScrollBehavior() }); setOpenedTag({ - songId, + songIdentity, sectionId: tag.section.id, sectionIndex: tagSectionIndex, holdingRoleId: tag.holdingRole?.id ?? null, From 796948427dfee8ab3a9f25bb9abd6bf35c38796a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:43:10 -0700 Subject: [PATCH 27/32] fix(workspace): narrow opened tag state --- apps/desktop/src/features/workspace/FirstTagCallout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 59792ba0b..76d0d5cfa 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -68,7 +68,8 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { } const opened = - openedTag?.songIdentity === songIdentity && + openedTag !== null && + openedTag.songIdentity === songIdentity && openedTag.sectionId === tag.section.id && openedTag.sectionIndex === tagSectionIndex && openedTag.holdingRoleId === (tag.holdingRole?.id ?? null) && From b8d70dd8049ff898b39a33f3a288ec4146639256 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:43:25 -0700 Subject: [PATCH 28/32] test(workspace): mount first-tag timeline region --- .../features/workspace/FirstTagCallout.reduced-motion.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx index d05afab49..fd0f5a1fc 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx @@ -49,6 +49,8 @@ describe("FirstTagCallout reduced motion", () => { const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const target = document.createElement("div"); From d054d9a8ab9a30885077e420a4bb3c12d9dd2dd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:43:38 -0700 Subject: [PATCH 29/32] test(workspace): mirror first-tag renderer contract --- .../src/features/workspace/FirstTagCallout.particle.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx index 62eea8d77..8093c7085 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx @@ -22,6 +22,8 @@ describe("FirstTagCallout Korean role copy", () => { const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); const target = document.createElement("div"); target.dataset.sectionIndex = "0"; Object.defineProperty(target, "scrollIntoView", { From b5672155c7ddf20b28a3f255a1545eddd0a03b0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:18:25 -0700 Subject: [PATCH 30/32] test(workspace): cover localized tag navigation target --- .../workspace/FirstTagCallout.test.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx index 8f2af4697..f94f0bc77 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.test.tsx @@ -30,11 +30,12 @@ function songWithTag() { return song; } -function appendSongStructureTarget() { +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { const timeline = document.createElement("div"); timeline.setAttribute("role", "region"); - timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + timeline.setAttribute("aria-label", ariaLabel); const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const unrelatedSibling = document.createElement("div"); @@ -122,6 +123,19 @@ describe("FirstTagCallout", () => { grid.remove(); }); + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); + + grid.remove(); + }); + it("does not claim map navigation completed when the rendered section target is missing", () => { render(); From 86c80bc5dfef719305ba6e2f8b03bd96dab88c1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:20:32 -0700 Subject: [PATCH 31/32] fix(workspace): decouple tag navigation from accessible copy --- apps/desktop/src/features/workspace/FirstTagCallout.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.tsx index 76d0d5cfa..f63c16de4 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.tsx @@ -100,9 +100,7 @@ export function FirstTagCallout({ song }: FirstTagCalloutProps) { type="button" className="mt-3 min-h-11 bg-gradient-to-r from-amber-300 to-rose-300 font-black text-slate-950" onClick={() => { - const renderer = document.querySelector( - '[role="region"][aria-label="Scrollable song structure timeline"]' - ); + const renderer = document.querySelector('[data-testid="song-structure-grid"]'); const target = tagSectionIndex >= 0 ? (renderer?.querySelector( From 6b83256ff8415d5e8afb0c034f97c6d40674ed79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:02:21 -0700 Subject: [PATCH 32/32] test(workspace): cover missing matchMedia fallback --- .../FirstTagCallout.reduced-motion.test.tsx | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx index fd0f5a1fc..ecf6c31cf 100644 --- a/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx @@ -30,6 +30,26 @@ function songWithTag() { return song; } +function appendTagTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const first = document.createElement("div"); + first.dataset.sectionIndex = "0"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + describe("FirstTagCallout reduced motion", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -46,23 +66,7 @@ describe("FirstTagCallout reduced motion", () => { removeEventListener: vi.fn(), dispatchEvent: vi.fn() })); - - const grid = document.createElement("div"); - grid.dataset.testid = "song-structure-grid"; - grid.setAttribute("role", "region"); - grid.setAttribute("aria-label", "Scrollable song structure timeline"); - const first = document.createElement("div"); - first.dataset.sectionIndex = "0"; - const target = document.createElement("div"); - target.dataset.sectionIndex = "1"; - const scrollIntoView = vi.fn(); - Object.defineProperty(target, "scrollIntoView", { - configurable: true, - value: scrollIntoView - }); - grid.appendChild(first); - grid.appendChild(target); - document.body.appendChild(grid); + const { grid, scrollIntoView } = appendTagTarget(); render(); fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); @@ -70,4 +74,15 @@ describe("FirstTagCallout reduced motion", () => { grid.remove(); }); + + it("uses smooth scrolling when matchMedia is unavailable", () => { + vi.stubGlobal("matchMedia", undefined); + const { grid, scrollIntoView } = appendTagTarget(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); });