-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace): start tonight's first part from the ready board #901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
6cabd1b
c79643e
940ff24
86ce266
b058780
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { Workspace } from "./Workspace"; | ||
|
|
||
| const originalLanguage = navigator.language; | ||
| const originalMatchMedia = window.matchMedia; | ||
| const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; | ||
|
|
||
| function setNavigatorLanguage(language: string): void { | ||
| Object.defineProperty(navigator, "language", { | ||
| configurable: true, | ||
| value: language | ||
| }); | ||
| } | ||
|
|
||
| describe("Workspace pick-part reduced-motion navigation", () => { | ||
| afterEach(() => { | ||
| setNavigatorLanguage(originalLanguage); | ||
| Object.defineProperty(window, "matchMedia", { | ||
| configurable: true, | ||
| value: originalMatchMedia | ||
| }); | ||
| Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { | ||
| configurable: true, | ||
| value: originalScrollIntoView | ||
| }); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("uses non-animated scrolling when reduced motion is requested", () => { | ||
| setNavigatorLanguage("en-US"); | ||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| Object.defineProperty(window, "matchMedia", { | ||
| configurable: true, | ||
| value: vi.fn((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() | ||
| })) | ||
| }); | ||
|
|
||
| render(<Workspace song={createDemoRehearsalSong()} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Start as Bass Guitar" })); | ||
|
|
||
| expect(scrollIntoView).toHaveBeenCalledWith({ | ||
| behavior: "auto", | ||
| block: "nearest" | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; | |
| import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; | ||
| import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4 } from "lucide-react"; | ||
| import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4, Users } from "lucide-react"; | ||
|
|
||
| interface WorkspaceProps { | ||
| song: RehearsalSong; | ||
|
|
@@ -71,6 +71,28 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro | |
| } | ||
| } | ||
|
|
||
| /** Substitute the player-facing role name into a copy template. */ | ||
| function fillRoleCopy(template: string, roleName: string): string { | ||
| return template.replaceAll("{role}", roleName); | ||
| } | ||
|
|
||
| /** Scroll the roles board into view after the player starts a part. */ | ||
| function focusWorkspaceRolesCard(): void { | ||
| const node = document.getElementById("workspace-roles-card"); | ||
| if (!(node instanceof HTMLElement)) { | ||
| return; | ||
| } | ||
| const reduceMotion = | ||
| typeof window.matchMedia === "function" && | ||
| window.matchMedia("(prefers-reduced-motion: reduce)").matches; | ||
| if (typeof node.scrollIntoView === "function") { | ||
| node.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" }); | ||
| } | ||
| if (typeof node.focus === "function") { | ||
| node.focus(); | ||
| } | ||
|
Comment on lines
+91
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Focus jump cancels the smooth scroll to the part board After starting a part, Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| /** Documented. */ | ||
| const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { | ||
| return ( | ||
|
|
@@ -151,6 +173,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp | |
| return roleMap.get(activeRole); | ||
| }, [activeRole, roleMap]); | ||
| const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; | ||
| const firstRole = allRoles[0]; | ||
| const firstRoleName = firstRole?.name.trim() || t("workspacePickPartFallback"); | ||
|
|
||
| /** Start tonight's part view on the first extracted role. */ | ||
| const handlePickFirstPart = () => { | ||
| if (!firstRole) { | ||
| return; | ||
| } | ||
| setActiveRole(firstRole.id); | ||
| focusWorkspaceRolesCard(); | ||
| }; | ||
|
Comment on lines
+176
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: No-roles fallback path is handled With no roles, Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); | ||
| const firstRangeCopy = firstRange | ||
| ? fillRangeCopy( | ||
|
|
@@ -336,7 +370,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp | |
| </div> | ||
| </div> | ||
| ) : ( | ||
| <p className="mt-2 text-sm leading-6 text-slate-300">{t("workspaceCollaborationEmpty")}</p> | ||
| <div className="mt-2 space-y-3"> | ||
| <p className="text-sm leading-6 text-slate-300"> | ||
| {fillRoleCopy(t("workspaceCollaborationEmpty"), firstRoleName)} | ||
| </p> | ||
| {firstRole ? ( | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handlePickFirstPart} | ||
| className="min-h-10 border-emerald-300/30 bg-emerald-300/10 font-semibold text-emerald-50 hover:bg-emerald-300/20 hover:text-white" | ||
| aria-label={fillRoleCopy(t("workspaceCollaborationPickPart"), firstRoleName)} | ||
| > | ||
| <Users className="mr-2 size-4 text-emerald-200" aria-hidden="true" /> | ||
| {fillRoleCopy(t("workspaceCollaborationPickPart"), firstRoleName)} | ||
| </Button> | ||
| ) : null} | ||
| </div> | ||
| )} | ||
| </section> | ||
|
|
||
|
|
@@ -355,11 +406,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp | |
|
|
||
| <SongStructure sections={song.sections} t={t} /> | ||
|
|
||
| <section className="rounded-2xl border border-white/10 bg-white/[0.04] p-4"> | ||
| <section | ||
| id="workspace-roles-card" | ||
| tabIndex={-1} | ||
| className="rounded-2xl border border-white/10 bg-white/[0.04] p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" | ||
| > | ||
| <div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> | ||
| <div> | ||
| <p className="text-xs font-black uppercase tracking-[0.24em] text-slate-300">{t("workspaceRolesHarmonyLabel")}</p> | ||
| <p className="mt-1 text-sm text-slate-400">Filter the board by player or vocal role without losing the full form context.</p> | ||
| <p className="mt-1 text-sm text-slate-400">{t("workspaceRolesHarmonyHint")}</p> | ||
| </div> | ||
| <RoleSwitcher | ||
| roles={allRoles} | ||
|
|
@@ -368,6 +423,28 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp | |
| /> | ||
| </div> | ||
|
|
||
| {!activeRole && firstRole ? ( | ||
| <div | ||
| data-testid="workspace-pick-part-callout" | ||
| className="mb-4 rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4" | ||
| > | ||
| <p className="text-sm leading-6 text-slate-200"> | ||
| {fillRoleCopy(t("workspacePickPartHint"), firstRoleName)} | ||
| </p> | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handlePickFirstPart} | ||
| className="mt-3 min-h-10 border-cyan-300/30 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20 hover:text-white" | ||
| aria-label={fillRoleCopy(t("workspacePickPartAction"), firstRoleName)} | ||
| > | ||
| <Users className="mr-2 size-4 text-cyan-200" aria-hidden="true" /> | ||
| {fillRoleCopy(t("workspacePickPartAction"), firstRoleName)} | ||
| </Button> | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {activeRole && ( | ||
| <div className="mb-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/[0.06] p-4"> | ||
| <p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200">Stem Player</p> | ||
|
|
@@ -512,4 +589,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp | |
| </Card> | ||
| </div> | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Role-name substitution honors $ replacement patterns
fillRoleCopysubstitutes the role name viareplaceAll("{role}", roleName), so a name containing$&or$1would be reinterpreted rather than inserted verbatim. Role names come from validated analysis output, so this is harmless today, but it differs fromfillRangeCopy(apps/desktop/src/features/workspace/firstRangeSqueeze.ts:166-172), which uses a function replacer immune to this.Was this helpful? React with 👍 or 👎 to provide feedback.