diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..dc31d9f09 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. +- After analysis, the ready workspace must name the first part to start. Do not leave Roles & Harmony or an empty collaboration card as a description that never opens tonight's chords, range, or simplification. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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 ca0df5ac4..20b21e10d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,6 +68,7 @@ Last updated: 2026-03-11 - BandScope is not only a shell around chord labels, stems, and ranges. - The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority. - These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer. +- After analysis, the ready workspace must start the first extracted part so tonight's chords, range, and simplification are one action away. Empty collaboration copy must start that same part instead of waiting for assignments. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..1d764cdc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Ready workspace now names the first extracted part and starts that role board so tonight's chords, range, and simplification are one action away. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..8f94de504 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +After analysis, Roles & Harmony and empty collaboration cards must name the first part and start that role board. Do not leave those ready-state surfaces as dead-end descriptions. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/features/workspace/Workspace.pick-part-reduced-motion.test.tsx b/apps/desktop/src/features/workspace/Workspace.pick-part-reduced-motion.test.tsx new file mode 100644 index 000000000..30586d591 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.pick-part-reduced-motion.test.tsx @@ -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(); + fireEvent.click(screen.getByRole("button", { name: "Start as Bass Guitar" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..045181e9c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -325,5 +325,49 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Bass Guitar로 시작" })).toBeTruthy(); + }); + + it("names the first part and opens that role board from the ready workspace", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("workspace-pick-part-callout")).toBeTruthy(); + expect(screen.getByText(/Start as Bass Guitar to see tonight's chords/i)).toBeTruthy(); + expect(screen.queryByText(/The bass holds the vi center/i)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Start as Bass Guitar" })); + + expect(screen.queryByTestId("workspace-pick-part-callout")).toBeNull(); + expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); + expect(screen.getByRole("tab", { name: "Bass Guitar", selected: true })).toBeTruthy(); + }); + + it("starts the first part from an empty collaboration card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.collaboration = undefined; + + render(); + + expect(screen.getByText(/No assignments yet. Start as Bass Guitar/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Start the room as Bass Guitar" })); + + expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); + }); + + it("hides the pick-part next action when the song has no roles", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.collaboration = undefined; + + render(); + + expect(screen.queryByTestId("workspace-pick-part-callout")).toBeNull(); + expect(screen.queryByRole("button", { name: /Start as/i })).toBeNull(); + expect(screen.getByText(/No assignments yet. Start as your part/i)).toBeTruthy(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..99811e53f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -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(); + } +} + /** 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(); + }; + const firstRange = useMemo(() => firstRangeSqueeze(song, activeRole), [activeRole, song]); const firstRangeCopy = firstRange ? fillRangeCopy( @@ -336,7 +370,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ) : ( -

{t("workspaceCollaborationEmpty")}

+
+

+ {fillRoleCopy(t("workspaceCollaborationEmpty"), firstRoleName)} +

+ {firstRole ? ( + + ) : null} +
)} @@ -355,11 +406,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp -
+

{t("workspaceRolesHarmonyLabel")}

-

Filter the board by player or vocal role without losing the full form context.

+

{t("workspaceRolesHarmonyHint")}

+ {!activeRole && firstRole ? ( +
+

+ {fillRoleCopy(t("workspacePickPartHint"), firstRoleName)} +

+ +
+ ) : null} + {activeRole && (

Stem Player

@@ -512,4 +589,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..e1a11dbf7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -39,7 +39,8 @@ "workspaceRehearsalTimelineLabel": "Rehearsal timeline", "workspaceSongTimelineLabel": "Song Timeline", "workspaceCollaborationLabel": "Collaboration", - "workspaceCollaborationEmpty": "Assignments, comments, and approvals will show up here as the room aligns.", + "workspaceCollaborationEmpty": "No assignments yet. Start as {role} so the room can see who locks this part tonight.", + "workspaceCollaborationPickPart": "Start the room as {role}", "workspaceSyncStatusLabel": "Sync", "workspaceAssignmentsLabel": "Assignments", "workspaceCommentsLabel": "Comments", @@ -50,6 +51,10 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", + "workspaceRolesHarmonyHint": "Pick your part to open tonight's chords, range, and simplification.", + "workspacePickPartHint": "Start as {role} to see tonight's chords, range, and what to simplify.", + "workspacePickPartAction": "Start as {role}", + "workspacePickPartFallback": "your part", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", "sectionGrooveLabel": "Groove", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..a9232b443 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -39,7 +39,8 @@ "workspaceRehearsalTimelineLabel": "합주 타임라인", "workspaceSongTimelineLabel": "곡 타임라인", "workspaceCollaborationLabel": "협업", - "workspaceCollaborationEmpty": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.", + "workspaceCollaborationEmpty": "아직 담당이 없습니다. {role}로 시작하면 오늘 이 파트를 누가 잠글지 합주실에 보입니다.", + "workspaceCollaborationPickPart": "합주실을 {role}로 시작", "workspaceSyncStatusLabel": "동기화", "workspaceAssignmentsLabel": "담당", "workspaceCommentsLabel": "코멘트", @@ -50,6 +51,10 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", + "workspaceRolesHarmonyHint": "파트를 고르면 오늘 칠 화성, 음역, 단순화 힌트가 열립니다.", + "workspacePickPartHint": "오늘 코드, 음역, 단순화할 자리를 보려면 {role}로 시작하세요.", + "workspacePickPartAction": "{role}로 시작", + "workspacePickPartFallback": "내 파트", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..205e4bed6 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -28,7 +28,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Metric Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-216 | `apps/desktop/src/App.tsx` | Feature-local `MetricCard({ icon, label, value, detail, accent? })`. Metrics follow source controls on mobile. | | Confidence Badge | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-239 | `apps/desktop/src/features/workspace/ConfidenceBadge.tsx` | Use `level: ConfidenceLevel`; no `score` or `label` prop exists in current code. | | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | -| Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | +| Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. When no role is selected, Workspace must name the first part and start that board. | | 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. |