Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
95e74ee
feat(workspace): guide tonight's first pre-chorus on map and player
seonghobae Aug 20, 2026
711bfd4
test(workspace): reject unbounded pre-chorus display times
seonghobae Aug 20, 2026
7159431
fix(workspace): bound pre-chorus display times
seonghobae Aug 20, 2026
5609a7a
docs(i18n): describe translator contract
seonghobae Aug 20, 2026
78d916f
test(player): require locale-consistent Korean summary
seonghobae Aug 20, 2026
40130a1
feat(i18n): add localized player summary copy
seonghobae Aug 20, 2026
cb7ce76
feat(i18n): add Korean player summary copy
seonghobae Aug 20, 2026
d1975e1
fix(player): localize pre-chorus summary surface
seonghobae Aug 20, 2026
4c0cf96
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 25, 2026
1e9fcc8
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 26, 2026
4058e50
fix(changelog): preserve heading spacing
seonghobae Aug 28, 2026
73410af
repair(workspace): reconcile first pre-chorus lane with protected dev…
seonghobae Sep 23, 2026
0f211b0
repair(workspace): remove repository-wide authority from pre-chorus f…
seonghobae Sep 23, 2026
16003ee
test(workspace): preserve pre-chorus completion across same-song updates
seonghobae Sep 23, 2026
dce5b8b
fix(workspace): key pre-chorus completion by stable song identity
seonghobae Sep 23, 2026
be7c426
repair(player): drop uncallable pre-chorus Hear surface from this lane
seonghobae Sep 23, 2026
02a1167
repair(workspace): keep pre-chorus action in workspace owner
seonghobae Sep 23, 2026
11c68d3
repair(i18n): remove player-only pre-chorus copy
seonghobae Sep 23, 2026
502b7c2
repair(i18n): remove player-only pre-chorus copy
seonghobae Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstPreChorusCallout } from "./FirstPreChorusCallout";

function songWithPreChorus() {
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
const preChorus = structuredClone(seed);
preChorus.id = "pre-chorus-1";
preChorus.label = "pre-chorus";
preChorus.timeRange = { start: 20, end: 28 };
preChorus.roles = [
{
...seed.roles[2]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
preChorus.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [preChorus];
return song;
}

describe("FirstPreChorusCallout reduced motion", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("scrolls immediately when the operating system requests reduced motion", () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}));

const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const target = document.createElement("div");
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);

render(<FirstPreChorusCallout song={songWithPreChorus()} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });

grid.remove();
});
});
184 changes: 184 additions & 0 deletions apps/desktop/src/features/workspace/FirstPreChorusCallout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FirstPreChorusCallout } from "./FirstPreChorusCallout";

function songWithPreChorus() {
const song = createDemoRehearsalSong();
const seed = song.sections[0]!;
const preChorus = structuredClone(seed);
preChorus.id = "pre-chorus-1";
preChorus.label = "pre-chorus";
preChorus.timeRange = { start: 20, end: 28 };
preChorus.roles = [
{
...seed.roles[2]!,
id: "lead-vocal",
name: "Lead Vocal",
rehearsalPriority: "high"
}
];
preChorus.partGraph = [
{
role_id: "lead-vocal",
is_active: true,
handoff_to: [],
handoff_from: []
}
];
song.sections = [preChorus];
return song;
}

function appendSongStructureTarget() {
const grid = document.createElement("div");
grid.dataset.testid = "song-structure-grid";
const target = document.createElement("div");
const scrollIntoView = vi.fn();
Object.defineProperty(target, "scrollIntoView", {
configurable: true,
value: scrollIntoView
});
grid.appendChild(target);
document.body.appendChild(grid);
return { grid, scrollIntoView };
}

describe("FirstPreChorusCallout", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("names the first pre-chorus as map navigation, scrolls to its rendered section, and arms that action", () => {
const { grid, scrollIntoView } = appendSongStructureTarget();

render(<FirstPreChorusCallout song={songWithPreChorus()} />);

const action = screen.getByRole("button", {
name: "Open Lead Vocal pre-chorus at 0:20"
});
expect(action).toBeTruthy();
fireEvent.click(action);
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
expect(
screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeTruthy();

grid.remove();
});

it("does not claim map navigation completed when the rendered section target is missing", () => {
render(<FirstPreChorusCallout song={songWithPreChorus()} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));

expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy();
expect(
screen.queryByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeNull();
});

it("navigates by renderer-owned section position instead of untrusted analysis ids", () => {
const song = songWithPreChorus();
song.sections[0]!.id = "analysis section / duplicate";
const { grid, scrollIntoView } = appendSongStructureTarget();

render(<FirstPreChorusCallout song={song} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

grid.remove();
});

it("shows fresh guidance when the first pre-chorus changes or returns later", () => {
const initialSong = songWithPreChorus();
const { grid } = appendSongStructureTarget();
const { rerender } = render(<FirstPreChorusCallout song={initialSong} />);
fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));
expect(
screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeTruthy();

const nextSong = songWithPreChorus();
nextSong.id = "next-song";
nextSong.sections[0]!.timeRange = { start: 48, end: 56 };
rerender(<FirstPreChorusCallout song={nextSong} />);
expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:48.")).toBeTruthy();

grid.remove();
});

it("keeps completed guidance across an unrelated update to the same stable song", () => {
const initialSong = songWithPreChorus();
const { grid } = appendSongStructureTarget();
const { rerender } = render(<FirstPreChorusCallout song={initialSong} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));
expect(
screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeTruthy();

const updatedSong = structuredClone(initialSong);
updatedSong.title = `${initialSong.title} (edited)`;
rerender(<FirstPreChorusCallout song={updatedSong} />);

expect(
screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeTruthy();

grid.remove();
});

it("does not carry completed guidance into a replacement song with an invalid runtime id", () => {
const firstSong = songWithPreChorus();
const replacementSong = songWithPreChorus();
(firstSong as unknown as { id: unknown }).id = null;
(replacementSong as unknown as { id: unknown }).id = null;
const { grid } = appendSongStructureTarget();
const { rerender } = render(<FirstPreChorusCallout song={firstSong} />);

fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal pre-chorus at 0:20" }));
expect(
screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeTruthy();

rerender(<FirstPreChorusCallout song={replacementSong} />);

expect(screen.getByText("Lead Vocal carries the pre-chorus at 0:20.")).toBeTruthy();
expect(
screen.queryByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
).toBeNull();

grid.remove();
});

it("keeps an unavailable pre-chorus guidance-only", () => {
const song = createDemoRehearsalSong();
song.sections[0]!.label = "chorus";
render(<FirstPreChorusCallout song={song} />);
expect(screen.queryByRole("button")).toBeNull();
expect(
screen.getByText("No pre-chorus yet. Stay on tonight's map until the first lift is labeled.")
).toBeTruthy();
});

it("names a band-wide lift when no part holds the pre-chorus", () => {
const song = songWithPreChorus();
song.sections[0]!.partGraph[0]!.is_active = false;
render(<FirstPreChorusCallout song={song} />);
expect(screen.getByRole("button", { name: "Open the first pre-chorus at 0:20" })).toBeTruthy();
expect(screen.getByText("The band carries the pre-chorus at 0:20.")).toBeTruthy();
});

it("localizes the pre-chorus form label instead of exposing its raw enum in Korean copy", () => {
vi.stubGlobal("navigator", { language: "ko-KR" });
const song = songWithPreChorus();
song.sections[0]!.roles[0]!.name = "리드 보컬";

render(<FirstPreChorusCallout song={song} />);

expect(screen.getByText("리드 보컬이 0:20 프리코러스에서 리프트를 잡습니다.")).toBeTruthy();
expect(screen.queryByText(/pre-chorus에서/)).toBeNull();
});
});
146 changes: 146 additions & 0 deletions apps/desktop/src/features/workspace/FirstPreChorusCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 { formatPreChorusTime, resolveFirstPreChorus } from "./firstPreChorus";

/** Props for the first-pre-chorus rehearsal callout. */
export interface FirstPreChorusCalloutProps {
song: RehearsalSong;
}

type PreChorusCopyValues = Readonly<Record<"role" | "section" | "at", string>>;

type HeardPreChorus = Readonly<{
songIdentity: string | RehearsalSong;
sectionId: string;
sectionIndex: number;
holdingRoleId: string | null;
atSeconds: number;
}>;

/** Use a plain own song id when available; malformed/accessor-backed runtime ids fall back to object identity. */
function preChorusSongIdentity(song: RehearsalSong): string | RehearsalSong {
const descriptor = Object.getOwnPropertyDescriptor(song, "id");
if (
descriptor &&
"value" in descriptor &&
typeof descriptor.value === "string" &&
descriptor.value.trim().length > 0
) {
return descriptor.value;
}
return song;
}

/** Interpolate pre-chorus placeholders once so rehearsal data is never rescanned as template syntax. */
function formatPreChorusCopy(template: string, values: PreChorusCopyValues): string {
return template.replace(/\{(role|section|at)\}/g, (placeholder) => {
const key = placeholder.slice(1, -1) as keyof PreChorusCopyValues;
return values[key] ?? placeholder;
});
}

/** Use immediate scrolling when the operating system requests reduced motion. */
function preferredPreChorusScrollBehavior(): ScrollBehavior {
return typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth";
}

/** Name tonight's first labeled pre-chorus and open its renderer-owned map section. */
export function FirstPreChorusCallout({ song }: FirstPreChorusCalloutProps) {
const locale = detectPreferredLocale();
const t = createTranslator(locale);
const runtimeSong = song as unknown as Partial<RehearsalSong> | null;
const songIdentity = preChorusSongIdentity(song);
const preChorus = resolveFirstPreChorus(song);
const preChorusSectionIndex =
preChorus && Array.isArray(runtimeSong?.sections)
? runtimeSong.sections.indexOf(preChorus.section)
: -1;
const [heardPreChorus, setHeardPreChorus] = useState<HeardPreChorus | null>(null);

useEffect(() => {
setHeardPreChorus(null);
}, [
songIdentity,
preChorusSectionIndex,
preChorus?.section.id,
preChorus?.holdingRole?.id,
preChorus?.atSeconds
]);

if (!preChorus) {
return (
<aside
id="workspace-surface-pre-chorus"
className="rounded-2xl border border-sky-300/20 bg-sky-300/[0.06] p-4"
aria-label={t("firstPreChorusUnavailable")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-sky-200">{t("firstPreChorusLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{t("firstPreChorusUnavailable")}</p>
</aside>
);
}

const heard =
heardPreChorus?.songIdentity === songIdentity &&
heardPreChorus.sectionId === preChorus.section.id &&
heardPreChorus.sectionIndex === preChorusSectionIndex &&
heardPreChorus.holdingRoleId === (preChorus.holdingRole?.id ?? null) &&
heardPreChorus.atSeconds === preChorus.atSeconds;
const at = formatPreChorusTime(preChorus.atSeconds);
const copyValues: PreChorusCopyValues = {
role: preChorus.holdingRole?.name ?? "",
section: translateSectionFormLabel(locale, preChorus.section.label),
at
};
const hasRole = preChorus.holdingRole !== null;
const actionLabel = formatPreChorusCopy(
t(hasRole ? "firstPreChorusOpenAction" : "firstPreChorusOpenActionBand"),
copyValues
);
const body = formatPreChorusCopy(t(hasRole ? "firstPreChorusBody" : "firstPreChorusBodyBand"), copyValues);
const armed = formatPreChorusCopy(t(hasRole ? "firstPreChorusArmed" : "firstPreChorusArmedBand"), copyValues);

return (
<aside
id="workspace-surface-pre-chorus"
className="rounded-2xl border border-sky-300/20 bg-sky-300/[0.06] p-4"
aria-label={t("firstPreChorusLabel")}
>
<p className="text-xs font-black uppercase tracking-[0.24em] text-sky-200">{t("firstPreChorusLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{heard ? armed : body}</p>
<Button
type="button"
className="mt-3 min-h-11 bg-gradient-to-r from-sky-300 to-amber-300 font-black text-slate-950"
onClick={() => {
const grid = document.querySelector('[data-testid="song-structure-grid"]');
const target = preChorusSectionIndex >= 0 ? grid?.children.item(preChorusSectionIndex) : null;
if (typeof target?.scrollIntoView !== "function") {
return;
}
target.scrollIntoView({
block: "nearest",
behavior: preferredPreChorusScrollBehavior()
});
setHeardPreChorus({
songIdentity,
sectionId: preChorus.section.id,
sectionIndex: preChorusSectionIndex,
holdingRoleId: preChorus.holdingRole?.id ?? null,
atSeconds: preChorus.atSeconds
});
}}
>
{actionLabel}
</Button>
</aside>
);
}
Loading
Loading