diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..878329442 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. +- Workspace components stay on `docs/design-system/component-contract.md` and Figma node 19-239. Storybook covers ConfidenceBadge, PracticeProgress, RehearsalCallout, RoleSwitcher, WorkspaceStates, SectionRoadmap, and the extracted OverlapWarningList. - 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..c76373a41 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Workspace Storybook stories cite Figma 19-239 and `docs/design-system/component-contract.md`. OverlapWarningList is the shared clash list for Section Roadmap and Ranges. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..176682b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Storybook stories and workspace tokens for ConfidenceBadge (Figma 19-239), PracticeProgress, RehearsalCallout, RoleSwitcher, WorkspaceStates, SectionRoadmap, and the extracted OverlapWarningList. ConfidenceBadge now exposes the Figma Compact and Default size states for every confidence level while preserving Compact for existing call sites. OverlapWarningList is the shared clash list for Roadmap and Ranges. The RehearsalCallout story keeps the first practice action enabled. - 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -14,6 +15,7 @@ ### Fixed +- Keep shared overlap warnings readable on both dark rehearsal surfaces and light Ranges cards by selecting surface-specific shared tokens and exposing both contexts in Storybook. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..34c8d6755 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). The ready workspace names tonight's first playable range and the next instrument check. `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 ready workspace names tonight's first playable range and the next instrument check. Workspace Storybook stories cite Figma 19-239 and `docs/design-system/component-contract.md`; OverlapWarningList is the shared clash list for Section Roadmap and Ranges. `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/ranges/index.test.tsx b/apps/desktop/src/features/ranges/index.test.tsx index 585c1f4af..3bdd54509 100644 --- a/apps/desktop/src/features/ranges/index.test.tsx +++ b/apps/desktop/src/features/ranges/index.test.tsx @@ -70,7 +70,7 @@ describe("RangesFeature", () => { it("renders overlap warnings", () => { render(); - expect(screen.getByText("⚠️ Clashing notes with Role 2")).toBeInTheDocument(); + expect(screen.getByText("Clashing notes with Role 2")).toBeInTheDocument(); }); it("renders transcription count when transcription exists", () => { diff --git a/apps/desktop/src/features/ranges/index.tsx b/apps/desktop/src/features/ranges/index.tsx index 1cbb020b4..716fcafab 100644 --- a/apps/desktop/src/features/ranges/index.tsx +++ b/apps/desktop/src/features/ranges/index.tsx @@ -1,6 +1,7 @@ +import { OverlapWarningList } from "../workspace/OverlapWarningList"; import type { RehearsalSong } from "@bandscope/shared-types"; -/** Documented. */ +/** Render per-role range, overlap, and transcription summaries for the loaded rehearsal song. */ export function RangesFeature(props: { title: string; song?: RehearsalSong | null }) { const { title, song } = props; @@ -37,25 +38,7 @@ export function RangesFeature(props: { title: string; song?: RehearsalSong | nul
🎵 {role.range.lowestNote} — {role.range.highestNote}
- {role.overlapWarnings.length > 0 && ( -
- {role.overlapWarnings.map((warning, wIndex) => ( -
- ⚠️ {warning} -
- ))} -
- )} + {role.transcription && role.transcription.length > 0 && (
Transcription available: {role.transcription.length} notes @@ -68,4 +51,4 @@ export function RangesFeature(props: { title: string; song?: RehearsalSong | nul ))} ); -} +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.stories.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.stories.tsx new file mode 100644 index 000000000..28ed6a6ca --- /dev/null +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfidenceBadge } from "./ConfidenceBadge"; + +/** Storybook metadata for the Figma 19-239 confidence-badge level and size matrix. */ +const meta = { + title: "Workspace/ConfidenceBadge", + component: ConfidenceBadge, + parameters: { + layout: "centered", + docs: { + description: { + component: + "Figma 19-239 Confidence Badge. Mirrors Level=Low/Medium/High and Size=Compact/Default with workspace tokens; existing call sites remain Compact by default.", + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["compact", "default"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Low-confidence compact badge, matching the backward-compatible Figma compact state. */ +export const Low: Story = { args: { level: "low" } }; +/** Low-confidence default-size badge from the Figma level/size matrix. */ +export const LowDefault: Story = { args: { level: "low", size: "default" } }; +/** Medium-confidence compact badge from the Figma level/size matrix. */ +export const Medium: Story = { args: { level: "medium" } }; +/** Medium-confidence default-size badge from the Figma level/size matrix. */ +export const MediumDefault: Story = { args: { level: "medium", size: "default" } }; +/** High-confidence compact badge from the Figma level/size matrix. */ +export const High: Story = { args: { level: "high" } }; +/** High-confidence default-size badge from the Figma level/size matrix. */ +export const HighDefault: Story = { args: { level: "high", size: "default" } }; diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.test.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.test.tsx new file mode 100644 index 000000000..e944a0844 --- /dev/null +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.test.tsx @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ConfidenceBadge } from "./ConfidenceBadge"; + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + confidenceLevelLow: "확신이 낮음", + confidenceLevelMedium: "귀로 한 번 더 확인", + confidenceLevelHigh: "믿고 가져가도 됨" + })[key] ?? key, + detectPreferredLocale: () => "ko" +})); + +describe("ConfidenceBadge", () => { + it("keeps the pointer tooltip on the localized confidence contract", () => { + render(); + + const badge = screen.getByText("믿고 가져가도 됨"); + expect(badge).toHaveAttribute("title", "믿고 가져가도 됨"); + expect(badge).not.toHaveAttribute("title", "Confidence: high"); + }); + + it("mirrors the Figma compact and default size variants without changing existing call sites", () => { + const { rerender } = render(); + + expect(screen.getByText("확신이 낮음")).toHaveClass( + "h-[var(--bandscope-confidence-compact-height)]" + ); + + rerender(); + + expect(screen.getByText("확신이 낮음")).toHaveClass( + "h-[var(--bandscope-confidence-default-height)]" + ); + }); + + it("matches the live Figma Caption/Status typography contract", () => { + render(); + + expect(screen.getByText("확신이 낮음")).toHaveClass( + "text-[11px]", + "font-medium", + "leading-[16px]", + "tracking-normal" + ); + }); + + it("keeps confidence colors mapped to the live Figma 19:239 semantic status variables", () => { + const tokenSource = readFileSync( + resolve(process.cwd(), "src/styles/workspace-tokens.css"), + "utf8" + ).replace(/\s+/g, " "); + + expect(tokenSource).toContain("--bandscope-status-danger: #fda4af;"); + expect(tokenSource).toContain("--bandscope-status-danger-bg: #fda4af14;"); + expect(tokenSource).toContain("--bandscope-status-danger-border: #fda4af4d;"); + expect(tokenSource).toContain("--bandscope-status-warning: #fcd34d;"); + expect(tokenSource).toContain("--bandscope-status-warning-bg: #fcd34d12;"); + expect(tokenSource).toContain("--bandscope-status-warning-border: #fcd34d33;"); + expect(tokenSource).toContain("--bandscope-status-success: #6ee7b7;"); + expect(tokenSource).toContain("--bandscope-status-success-bg: #6ee7b712;"); + expect(tokenSource).toContain("--bandscope-status-success-border: #6ee7b733;"); + + expect(tokenSource).toContain( + "--bandscope-confidence-low-fg: var(--bandscope-status-danger);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-low-bg: var(--bandscope-status-danger-bg);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-low-border: var(--bandscope-status-danger-border);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-medium-fg: var(--bandscope-status-warning);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-medium-bg: var(--bandscope-status-warning-bg);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-medium-border: var(--bandscope-status-warning-border);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-high-fg: var(--bandscope-status-success);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-high-bg: var(--bandscope-status-success-bg);" + ); + expect(tokenSource).toContain( + "--bandscope-confidence-high-border: var(--bandscope-status-success-border);" + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx index f3da0b8e0..8c1385e61 100644 --- a/apps/desktop/src/features/workspace/ConfidenceBadge.tsx +++ b/apps/desktop/src/features/workspace/ConfidenceBadge.tsx @@ -2,37 +2,49 @@ import type { ConfidenceLevel } from "@bandscope/shared-types"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Badge } from "@/components/ui/badge"; +/** Supported visual sizes from the Figma confidence-badge component contract. */ +export type ConfidenceBadgeSize = "compact" | "default"; + interface ConfidenceBadgeProps { level: ConfidenceLevel; + size?: ConfidenceBadgeSize; } -/** Documented. */ -export function ConfidenceBadge({ level }: ConfidenceBadgeProps) { +/** Render a localized confidence state using the Figma-backed workspace confidence variants. */ +export function ConfidenceBadge({ level, size = "compact" }: ConfidenceBadgeProps) { const t = createTranslator(detectPreferredLocale()); - + let label = ""; let colorClass = ""; - + switch (level) { case "low": label = t("confidenceLevelLow"); - colorClass = "bg-rose-100 text-rose-700 hover:bg-rose-100 border-rose-200"; + colorClass = + "border-[color:var(--bandscope-confidence-low-border)] bg-[var(--bandscope-confidence-low-bg)] text-[color:var(--bandscope-confidence-low-fg)] hover:bg-[var(--bandscope-confidence-low-bg)]"; break; case "medium": label = t("confidenceLevelMedium"); - colorClass = "bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200"; + colorClass = + "border-[color:var(--bandscope-confidence-medium-border)] bg-[var(--bandscope-confidence-medium-bg)] text-[color:var(--bandscope-confidence-medium-fg)] hover:bg-[var(--bandscope-confidence-medium-bg)]"; break; case "high": label = t("confidenceLevelHigh"); - colorClass = "bg-emerald-100 text-emerald-700 hover:bg-emerald-100 border-emerald-200"; + colorClass = + "border-[color:var(--bandscope-confidence-high-border)] bg-[var(--bandscope-confidence-high-bg)] text-[color:var(--bandscope-confidence-high-fg)] hover:bg-[var(--bandscope-confidence-high-bg)]"; break; } + const heightClass = + size === "default" + ? "h-[var(--bandscope-confidence-default-height)]" + : "h-[var(--bandscope-confidence-compact-height)]"; + return ( {label} diff --git a/apps/desktop/src/features/workspace/OverlapWarningList.stories.tsx b/apps/desktop/src/features/workspace/OverlapWarningList.stories.tsx new file mode 100644 index 000000000..48f705a8b --- /dev/null +++ b/apps/desktop/src/features/workspace/OverlapWarningList.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverlapWarningList } from "./OverlapWarningList"; + +const meta = { + title: "Workspace/OverlapWarningList", + component: OverlapWarningList, + parameters: { + layout: "padded", + docs: { + description: { + component: + "Shared clash list extracted from Section Roadmap and Ranges. Select the dark or light surface contract so the same next-player warning remains readable in both contexts.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SharedClashes: Story = { + args: { + warnings: ["Bass and vocal share C3", "Keys cover the guitar hook"], + }, +}; + +export const LightCard: Story = { + args: { + warnings: ["Bass and vocal share C3", "Keys cover the guitar hook"], + surface: "light", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const NoClashes: Story = { + args: { + warnings: [], + }, +}; \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/OverlapWarningList.test.tsx b/apps/desktop/src/features/workspace/OverlapWarningList.test.tsx new file mode 100644 index 000000000..b10711bbd --- /dev/null +++ b/apps/desktop/src/features/workspace/OverlapWarningList.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { OverlapWarningList } from "./OverlapWarningList"; + +describe("OverlapWarningList", () => { + it("renders nothing when there are no clashes", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists each clash so the next player action is visible", () => { + render(); + expect(screen.getByRole("list", { name: "Clash warning" })).toBeTruthy(); + expect(screen.getByText("Bass and vocal share C3")).toBeTruthy(); + expect(screen.getByText("Keys cover the guitar hook")).toBeTruthy(); + }); + + it("uses dedicated readable tokens when rendered on a light card", () => { + render(); + + const warning = screen.getByText("Bass and vocal share C3").closest("li"); + expect(warning).not.toBeNull(); + expect(warning?.className).toContain("border-[color:var(--bandscope-overlap-light-border)]"); + expect(warning?.className).toContain("bg-[var(--bandscope-overlap-light-bg)]"); + expect(warning?.className).toContain("text-[color:var(--bandscope-overlap-light-fg)]"); + }); + + it("keeps repeated clash evidence distinct without duplicate React keys", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + render(); + + expect(screen.getAllByText("Bass and vocal share C3")).toHaveLength(2); + expect( + consoleError.mock.calls.some((call) => call.map(String).join(" ").includes("same key")), + ).toBe(false); + } finally { + consoleError.mockRestore(); + } + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/OverlapWarningList.tsx b/apps/desktop/src/features/workspace/OverlapWarningList.tsx new file mode 100644 index 000000000..ba24c1ac4 --- /dev/null +++ b/apps/desktop/src/features/workspace/OverlapWarningList.tsx @@ -0,0 +1,36 @@ +import { AlertCircle } from "lucide-react"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; + +/** Shared clash list used by Section Roadmap and Ranges. */ +export interface OverlapWarningListProps { + warnings: readonly string[]; + surface?: "dark" | "light"; +} + +const SURFACE_CLASSES = { + dark: "border-[color:var(--bandscope-overlap-border)] bg-[var(--bandscope-overlap-bg)] text-[color:var(--bandscope-overlap-fg)]", + light: + "border-[color:var(--bandscope-overlap-light-border)] bg-[var(--bandscope-overlap-light-bg)] text-[color:var(--bandscope-overlap-light-fg)]", +} as const; + +/** Render localized rehearsal-overlap warnings, or nothing when no warnings exist. */ +export function OverlapWarningList({ warnings, surface = "dark" }: OverlapWarningListProps) { + const t = createTranslator(detectPreferredLocale()); + if (warnings.length === 0) { + return null; + } + + return ( +
    + {warnings.map((warning, index) => ( +
  • +
  • + ))} +
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/PracticeProgress.stories.tsx b/apps/desktop/src/features/workspace/PracticeProgress.stories.tsx new file mode 100644 index 000000000..339583125 --- /dev/null +++ b/apps/desktop/src/features/workspace/PracticeProgress.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PracticeProgress } from "./PracticeProgress"; + +/** Storybook metadata for rehearsal practice-progress controls. */ +const meta = { + title: "Workspace/PracticeProgress", + component: PracticeProgress, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Empty rehearsal-progress state at zero percent. */ +export const Empty: Story = { + args: { progress: 0, onChange: () => undefined }, +}; + +/** Midway rehearsal-progress state whose Storybook controls remain bound to component props. */ +export const Midway: Story = { + args: { progress: 40, onChange: () => undefined }, +}; diff --git a/apps/desktop/src/features/workspace/RehearsalCallout.stories.tsx b/apps/desktop/src/features/workspace/RehearsalCallout.stories.tsx new file mode 100644 index 000000000..9f7d8f85d --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalCallout.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RehearsalCallout } from "./RehearsalCallout"; + +const meta = { + title: "Workspace/RehearsalCallout", + component: RehearsalCallout, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LockChorusFirst: Story = { + args: { + title: "Lock the chorus bass first", + body: "Bass and vocal overlap on the hook. Loop the chorus before the room starts.", + actionLabel: "Loop the chorus", + onAction: () => undefined, + }, +}; diff --git a/apps/desktop/src/features/workspace/RehearsalCallout.test.tsx b/apps/desktop/src/features/workspace/RehearsalCallout.test.tsx new file mode 100644 index 000000000..90e173836 --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalCallout.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { RehearsalCallout } from "./RehearsalCallout"; + +describe("RehearsalCallout", () => { + it("names the first practice action", () => { + const onAction = vi.fn(); + render( + + ); + + expect(screen.getByLabelText("Lock the chorus bass first")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Loop the chorus" })); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it("marks an unwired practice action unavailable", () => { + render( + + ); + + expect(screen.getByText("Confirm the pickup")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Hear the pickup" })).toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/features/workspace/RehearsalCallout.tsx b/apps/desktop/src/features/workspace/RehearsalCallout.tsx new file mode 100644 index 000000000..74ceafc4c --- /dev/null +++ b/apps/desktop/src/features/workspace/RehearsalCallout.tsx @@ -0,0 +1,36 @@ +import { Button } from "@/components/ui/button"; +import { Lightbulb } from "lucide-react"; + +/** First-practice callout shown after analysis or in empty-state stories. */ +export interface RehearsalCalloutProps { + title: string; + body: string; + actionLabel: string; + onAction?: () => void; +} + +/** Render a rehearsal next-action callout and disable its button when no action is available. */ +export function RehearsalCallout({ title, body, actionLabel, onAction }: RehearsalCalloutProps) { + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.stories.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.stories.tsx new file mode 100644 index 000000000..61cee5fd8 --- /dev/null +++ b/apps/desktop/src/features/workspace/RoleSwitcher.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RoleSwitcher } from "./RoleSwitcher"; + +const roles = [ + { id: "bass-guitar", name: "Bass" }, + { id: "lead-vocal", name: "Lead vocal" }, + { id: "keys-right", name: "Keys RH" }, +]; + +/** Storybook metadata for the rehearsal role-switching tabs. */ +const meta = { + title: "Workspace/RoleSwitcher", + component: RoleSwitcher, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** All-roles state with Storybook controls bound directly to the switcher props. */ +export const AllRoles: Story = { + args: { roles, activeRole: null, onRoleChange: () => undefined }, +}; diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.stories.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.stories.tsx new file mode 100644 index 000000000..a5017c0f9 --- /dev/null +++ b/apps/desktop/src/features/workspace/SectionRoadmap.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { SectionRoadmap } from "./SectionRoadmap"; + +/** Storybook metadata for the section-by-section rehearsal roadmap. */ +const meta = { + title: "Workspace/SectionRoadmap", + component: SectionRoadmap, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Full demo-song roadmap showing all rehearsal roles. */ +export const DemoSong: Story = { + args: { + song: createDemoRehearsalSong(), + activeRole: null, + }, +}; diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..e888d85d4 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -94,6 +94,21 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).toHaveBeenCalledTimes(1); }); + it("keeps placeholder-looking rehearsal data literal in chord-edit labels", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const onSongUpdate = vi.fn(); + song.sections[0]!.roles[0]!.name = "{sectionLabel}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Edit chord for {sectionLabel} in verse, current C#m7" + }) + ).toBeTruthy(); + }); + it("does not update when the trimmed chord is unchanged", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..561fd79b6 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -2,6 +2,7 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; import { useId, useMemo } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; +import { OverlapWarningList } from "./OverlapWarningList"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -14,21 +15,32 @@ interface SectionRoadmapProps { onSongUpdate?: (song: RehearsalSong) => void; } -/** Documented. */ +type ChordEditCopyValues = Readonly>; + +/** Interpolate chord-edit placeholders once so rehearsal data is never reinterpreted as template syntax. */ +function formatChordEditLabel(template: string, values: ChordEditCopyValues): string { + return template.replace(/\{(roleName|sectionLabel|chord)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof ChordEditCopyValues; + return values[key]; + }); +} + +/** Render the section-by-section rehearsal roadmap, optionally filtered to one active role. */ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); - /** Documented. */ + /** Build the localized accessible label for editing one role's chord in a section. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { - return t("chordEditAriaLabel") - .replace("{roleName}", role.name) - .replace("{sectionLabel}", sectionLabel) - .replace("{chord}", role.harmony.chord); + return formatChordEditLabel(t("chordEditAriaLabel"), { + roleName: role.name, + sectionLabel, + chord: role.harmony.chord + }); }; - /** Documented. */ + /** Persist a non-empty changed chord as a user-owned harmony override for the selected role. */ const handleChordEdit = (sectionId: string, role: RehearsalRole) => { if (!onSongUpdate) return; const newChord = window.prompt(t("chordEditPrompt"), role.harmony.chord); @@ -74,14 +86,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma if (changed) onSongUpdate(updatedSong); }; - /** Documented. */ + + /** Map rehearsal priority to the roadmap card's visual emphasis tokens. */ const getPriorityColor = (priority: string) => { if (priority === "high") return "border-rose-400 bg-rose-400/[0.08] shadow-[0_0_30px_rgba(251,113,133,0.10)]"; if (priority === "medium") return "border-amber-300 bg-amber-300/[0.08] shadow-[0_0_30px_rgba(252,211,77,0.08)]"; return "border-emerald-300 bg-emerald-300/[0.08] shadow-[0_0_30px_rgba(110,231,183,0.08)]"; }; - /** Documented. */ + /** Select the decorative status icon that corresponds to a rehearsal priority. */ const getPriorityIcon = (priority: string) => { if (priority === "high") return
)} - {role.overlapWarnings.length > 0 && ( -
- {role.overlapWarnings.map((warning, wIdx) => ( -
-
- ))} -
- )} + diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx new file mode 100644 index 000000000..618c482b4 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmptyState, ErrorState, LoadingState } from "./WorkspaceStates"; + +/** Storybook metadata for the workspace empty, loading, and failed states. */ +const meta = { + title: "Workspace/WorkspaceStates", + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Empty workspace before a rehearsal source has been selected. */ +export const Empty: Story = { + render: () => , +}; + +/** Workspace while local analysis is still running. */ +export const Loading: Story = { + render: () => , +}; + +/** Failed workspace state with actionable decode-error copy. */ +export const Failed: Story = { + render: () => , +}; diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index e64ea931a..db9497b9e 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "@fontsource-variable/geist"; +@import "./styles/workspace-tokens.css"; /* stylelint-disable-next-line scss/at-rule-no-unknown -- Tailwind v4 directive is compiled by the Vite plugin. */ @custom-variant dark (&:is(.dark *)); diff --git a/apps/desktop/src/styles/workspace-tokens.css b/apps/desktop/src/styles/workspace-tokens.css new file mode 100644 index 000000000..cfd5c6e51 --- /dev/null +++ b/apps/desktop/src/styles/workspace-tokens.css @@ -0,0 +1,39 @@ +/* Workspace tokens mapped from Figma 19-239 Confidence Badge and page 31. */ +:root { + --bandscope-confidence-compact-height: 1.375rem; + --bandscope-confidence-default-height: 1.625rem; + + /* Canonical semantic status colors from the published BandScope Figma library. */ + --bandscope-status-danger: #fda4af; + --bandscope-status-danger-bg: #fda4af14; + --bandscope-status-danger-border: #fda4af4d; + --bandscope-status-warning: #fcd34d; + --bandscope-status-warning-bg: #fcd34d12; + --bandscope-status-warning-border: #fcd34d33; + --bandscope-status-success: #6ee7b7; + --bandscope-status-success-bg: #6ee7b712; + --bandscope-status-success-border: #6ee7b733; + + /* Confidence levels consume reusable status semantics instead of inventing a parallel palette. */ + --bandscope-confidence-low-bg: var(--bandscope-status-danger-bg); + --bandscope-confidence-low-fg: var(--bandscope-status-danger); + --bandscope-confidence-low-border: var(--bandscope-status-danger-border); + --bandscope-confidence-medium-bg: var(--bandscope-status-warning-bg); + --bandscope-confidence-medium-fg: var(--bandscope-status-warning); + --bandscope-confidence-medium-border: var(--bandscope-status-warning-border); + --bandscope-confidence-high-bg: var(--bandscope-status-success-bg); + --bandscope-confidence-high-fg: var(--bandscope-status-success); + --bandscope-confidence-high-border: var(--bandscope-status-success-border); + --bandscope-overlap-bg: rgb(251 113 133 / 0.08); + --bandscope-overlap-border: rgb(253 164 175 / 0.2); + --bandscope-overlap-fg: rgb(255 228 230); + --bandscope-overlap-light-bg: #fff1f2; + --bandscope-overlap-light-border: #fecdd3; + --bandscope-overlap-light-fg: #9f1239; + --bandscope-callout-bg: rgb(34 211 238 / 0.08); + --bandscope-callout-border: rgb(34 211 238 / 0.25); + --bandscope-callout-fg: rgb(207 250 254); + --bandscope-practice-track: rgb(15 23 42 / 0.5); + --bandscope-practice-fill-from: rgb(99 102 241); + --bandscope-practice-fill-to: rgb(34 211 238); +} \ No newline at end of file diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..17b13acb1 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -4,7 +4,7 @@ This contract connects the BandScope Figma design system to production React com Figma file: https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk -The authoritative Figma view is `31 Component Contract Catalog`. This file mirrors that page for review only. +The live design-system authority was re-verified on 2026-08-30. Page `45:86` (`31 Component Contract Catalog`) contains the current reusable `OverlapWarningItem` component set at node `254:81`, with `Surface=Dark` and `Surface=Light` variants. Contract frame `254:82` shows both variants against their intended host surfaces and records the no-warning state; live metadata confirms the older `252:19` node no longer exists and must not be used as design-gate evidence. Older component links below remain traceability unless their specific node is freshly verified. ## Canonical Components @@ -26,7 +26,9 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Console Panel | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-632 | `apps/desktop/src/components/ui/card.tsx` | Use `Card`, `CardHeader`, `CardTitle`, and `CardContent`; `size="sm"` is the compact state. | | BandScope Mark | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-163 | `apps/desktop/src/App.tsx` | Feature-local `BandScopeMark()` currently has no props; Figma size variants are visual guidance only. | | 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. | +| 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` plus optional `size` (`"compact"` or `"default"`); the compact size remains the backward-compatible default. No `score` or `label` prop exists. | +| Overlap Warning List | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=254-81 | `apps/desktop/src/features/workspace/OverlapWarningList.tsx` | Current reusable `OverlapWarningItem` component set. Pass `warnings`; default dark surface is for Roadmap and `surface="light"` is required on light cards such as Ranges. | +| Rehearsal Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/RehearsalCallout.tsx` | First practice action after analysis; keep a visible button. | | 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. | | 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. | @@ -57,8 +59,11 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro ### Badge And Confidence - Use `Badge` for general metadata and `ConfidenceBadge` for confidence status. +- Historical Figma node `19:239` had six variants: `Level=Low|Medium|High` crossed with `Size=Compact|Default`; re-verify that node before using it as current evidence. - Use `ConfidenceBadge` with `level` from shared types only: `low`, `medium`, `high`. -- Do not pass `score` or `label` to `ConfidenceBadge`; those props do not exist in the current runtime component. +- Omit `size` to preserve the existing compact rendering; pass `size="default"` when the 26 px state is required. Compact and default heights are workspace tokens corresponding to the prior 22 px and 26 px Figma variants at the default root font size. +- Do not pass `score` or `label` to `ConfidenceBadge`; those props do not exist in the runtime component. +- Storybook must retain both size states for every confidence level so design review can compare the complete variant matrix. - Keep badges short enough to avoid wrapping inside dense cards. ### Tabs @@ -73,18 +78,27 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro - Tone-specific colors belong on `ProgressIndicator` or scoped child selectors. - Provide adjacent live text when progress reflects an active asynchronous job. +### Overlap Warnings + +- Current Figma authority is the `OverlapWarningItem` component set `254:81` on page `45:86`; contract frame `254:82` is the current exact-state handoff. The former `252:19`/`252:2` references are stale and are not design-gate evidence. +- `OverlapWarningList` defaults to `surface="dark"` so Section Roadmap keeps the established dark-workspace token set. +- Ranges cards have a white background and must pass `surface="light"` so compact warning copy uses the dedicated light-surface tokens. +- Storybook must retain both dark and light populated states plus the empty state; do not copy the warning markup into a feature-specific implementation. +- Figma and Storybook both show the two surface states. Runtime empty state intentionally renders no node. +- Accessibility rationale and current exact-value mapping are recorded in `docs/doctoring/overlap-warning-surface-contrast.md`. + ### Workspace States -- Figma page `34 Workspace State Matrix` maps `EmptyState`, `LoadingState`, `ErrorState`, ready `Workspace`, `GrooveMap`, and Source Control Stack substates. +- Historical Figma page `34 Workspace State Matrix` mapped `EmptyState`, `LoadingState`, `ErrorState`, ready `Workspace`, `GrooveMap`, and Source Control Stack substates. Re-verify that specific state authority before changing workspace routing. - `App.tsx` must preserve the current routing order: `jobError` -> `ErrorState`, `analysisInFlight || isStarting` -> `LoadingState`, `jobResult` -> `Workspace`, otherwise `EmptyState`. - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. - `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy. - `EmptyState` must remain an actionable state card, not a blank placeholder panel. -- If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. +- If a new workspace state is added in code, update current Figma state authority before merging. ## Pattern Backlog -These Figma patterns are valid visual guidance but are not yet extracted as standalone code components. Use the existing feature markup and open a follow-up extraction task when reuse appears twice. +Historical Figma patterns remain implementation traceability unless freshly verified. Use existing feature markup and open a follow-up extraction task when reuse appears twice. | Figma pattern | Current implementation home | Extraction trigger | | --- | --- | --- | @@ -97,9 +111,9 @@ These Figma patterns are valid visual guidance but are not yet extracted as stan ## PR Review Rules -- New UI should cite the matching Figma node and code path in the PR description when it implements a design-system component. -- A new component variant must update this file, the relevant component tests, and the Figma component notes. +- New UI should cite a current matching Figma node and code path in the PR description when it implements a design-system component; historical or missing nodes are traceability only. +- A new component variant must update this file, the relevant component tests, Storybook, and the current Figma component notes before merge. - A new Figma-only pattern must enter the Pattern Backlog before being reused. - Any deliberate visual divergence from Figma should state whether the repo contract or accessibility requirement caused it. -- Workspace state changes must cite page `34 Workspace State Matrix` or explain why Figma was updated first. +- Workspace state changes must cite current Figma state authority or explain the design-authority repair before merge. - Do not add Code Connect, Figma token, or Figma publish requirements to CI. diff --git a/docs/doctoring/overlap-warning-surface-contrast.md b/docs/doctoring/overlap-warning-surface-contrast.md new file mode 100644 index 000000000..8c3442a4b --- /dev/null +++ b/docs/doctoring/overlap-warning-surface-contrast.md @@ -0,0 +1,48 @@ +# Overlap Warning Surface Contrast + +## Customer-facing defect + +`OverlapWarningList` is shared by the dark rehearsal workspace and the light Ranges cards. The component previously applied the dark-workspace foreground token in both contexts, so a warning rendered on a white Ranges card could lose readable text contrast even though the same component was legible on the dark workspace. + +## Repair contract + +The shared component keeps one semantic warning primitive and selects a surface contract instead of duplicating markup: + +- `surface="dark"` remains the default for the rehearsal workspace; +- `surface="light"` uses `--bandscope-overlap-light-bg`, `--bandscope-overlap-light-border`, and `--bandscope-overlap-light-fg` for light cards; +- the Ranges feature explicitly selects the light surface; +- Storybook exposes both surface contexts; and +- component tests require the light-surface token contract so a later refactor cannot silently reuse dark-only text colors. + +The light warning foreground/background pair is chosen to satisfy WCAG 2.2 Success Criterion 1.4.3 for normal text. WCAG 2.2 requires at least 4.5:1 contrast for ordinary text; large text has a separate 3:1 threshold. This component renders compact warning copy and therefore uses the normal-text requirement. + +## Design authority status + +The configured BandScope Figma file was freshly re-verified on 2026-08-30. Page `45:86` (`31 Component Contract Catalog`) contains the reusable `OverlapWarningItem` component set at node `254:81` with `Surface=Dark` and `Surface=Light` variants. Contract frame `254:82` renders real instances of the same component set against the dark rehearsal surface (`254:85`) and white Ranges-card surface (`254:96`), while `254:107` records the intentional `NoClashes` state in which the runtime renders no warning list. Fresh metadata also confirms that the former `252:19` reference is no longer present in the file, so it is historical/stale traceability and is not acceptable as design-gate evidence. + +The Figma values match the runtime/Storybook contract: + +- Dark: background `rgb(251 113 133 / 0.08)`, border `rgb(253 164 175 / 0.2)`, foreground `#FFE4E6`. +- Light: background `#FFF1F2`, border `#FECDD3`, foreground `#9F1239`. + +Older component node references in the broader BandScope catalog remain historical traceability unless their specific node is freshly verified. No Code Connect or Figma publishing requirement is added to CI; Storybook remains the executable design-review boundary and Figma remains the visual/component authority. + +## Security Notes + +This slice changes React presentation, Storybook coverage, shared CSS tokens, design-traceability documentation, and a static Figma design reference only. It does not add or widen filesystem handling, URL fetching, subprocess execution, IPC, WebView authority, updater behavior, model downloads, export paths, credential handling, or runtime network access. The light/dark warning selection operates only on already-provided warning strings and does not create a new trust boundary. + +## Verification + +Before merge, verify the unchanged exact head with: + +- focused `OverlapWarningList` component tests; +- Storybook rendering for dark, light, and empty states; +- the repository's configured Storybook accessibility checks; +- current live Figma metadata and screenshot evidence for component set `254:81`, contract frame `254:82`, dark preview `254:85`, light preview `254:96`, and empty-state note `254:107`; and +- every required repository and organization check plus qualifying independent human review. + +Queued, skipped-required, stale, predecessor-head, model-only, self/author, synthetic, or administrative-bypass evidence is not sufficient. + +## Reference + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/