diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..381f29524 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. +- Settings must name which audio this device can open and the next rehearsal action. It must not say coming soon. - 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..e876ceae0 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. +- The desktop Settings control lists admitted local audio formats and names the next action: choose a supported file, or open tonight's rehearsal map once a song is ready. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..08a5022eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Settings names which audio this device can open and the next rehearsal action instead of saying coming soon. - 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 the Settings entry reachable from the compact rehearsal navigation on narrow windows. - 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 @@ -74,4 +76,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..548586cd1 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. +Settings must name admitted local audio formats and the next rehearsal action. Help and Stem Lab stay out of this slice. + 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/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..fe41e97ca 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -217,7 +217,11 @@ describe("App", () => { expect(screen.getByRole("button", { name: /^Workspace$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Import$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /^Export$/i })).toBeTruthy(); - expect(fireEvent.click(screen.getByRole("button", { name: /settings coming soon/i }))).toBe(false); + const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i }); + expect(settingsButtons).toHaveLength(3); + for (const settingsButton of settingsButtons) { + expect(settingsButton).not.toHaveAttribute("aria-disabled"); + } expect(fireEvent.click(screen.getByRole("button", { name: /help coming soon/i }))).toBe(false); const primaryNav = screen.getByRole("navigation", { name: /primary rehearsal views/i }); const activePrimaryNavButton = within(primaryNav).getByRole("button", { name: "Workspace" }); @@ -1536,6 +1540,20 @@ describe("App", () => { expect(mockSaveProject).not.toHaveBeenCalled(); }); + it("opens Settings from the compact rehearsal navigation", () => { + render(); + + const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); + const settingsButton = within(compactNav).getByRole("button", { + name: /See which audio this device can open compact view/i + }); + + fireEvent.click(settingsButton); + + expect(settingsButton).toHaveAttribute("aria-current", "page"); + expect(screen.getByText("Tonight's audio")).toBeTruthy(); + }); + it("handles exception thrown by importYoutubeUrl itself", async () => { mockImportYoutubeUrlError = true; @@ -1553,16 +1571,124 @@ describe("App", () => { }); - it("renders Settings and Help as focusable aria-disabled controls", () => { + it("keeps Help coming soon while Settings names the next audio action", () => { render(); - const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); + const settingsButtons = screen.getAllByRole("button", { name: /See which audio this device can open/i }); const helpButton = screen.getByRole("button", { name: "Help coming soon" }); - expect(settingsButton).toHaveAttribute("aria-disabled", "true"); - expect(settingsButton).not.toHaveAttribute("disabled"); + expect(settingsButtons).toHaveLength(3); + for (const settingsButton of settingsButtons) { + expect(settingsButton).not.toHaveAttribute("aria-disabled"); + expect(settingsButton).not.toHaveAttribute("disabled"); + } + fireEvent.click(screen.getByRole("button", { name: /See which audio this device can open.*compact/i })); + expect(screen.getByRole("button", { name: /See which audio this device can open.*compact/i })).toHaveAttribute( + "aria-current", + "page" + ); expect(helpButton).toHaveAttribute("aria-disabled", "true"); expect(helpButton).not.toHaveAttribute("disabled"); }); + it("opens Settings to name admitted formats and start the local-audio picker", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + expect(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]).toHaveAttribute("aria-current", "page"); + expect(screen.getByText("Tonight's audio")).toBeTruthy(); + expect(screen.getByText(".wav")).toBeTruthy(); + expect(screen.getByText(".m4a")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Choose a supported file/i })); + await waitFor(() => { + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + }); + expect(screen.getByRole("heading", { name: /Workspace Home/i })).toBeTruthy(); + expect(screen.queryByText("Tonight's audio")).toBeNull(); + }); + + it.each(["queued", "running"] as const)("keeps the Settings chooser disabled while analysis is %s", async (state) => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ state })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) }; + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + fireEvent.click(settingsChooseButton); + + expect(screen.getByTitle("late-night-set.wav")).toBeTruthy(); + expect(screen.queryByText("replacement.wav")).toBeNull(); + }); + + it("keeps the Settings chooser disabled while analysis startup is pending", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockImplementationOnce(() => new Promise(() => undefined)); + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + mockLocalAudioSelectionResult = { ok: true, bootstrap: bootstrapResponse({ source: { fileName: "replacement.wav" } }) }; + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + fireEvent.click(settingsChooseButton); + + expect(screen.getByTitle("late-night-set.wav")).toBeTruthy(); + expect(screen.queryByText("replacement.wav")).toBeNull(); + }); + + it("keeps the Settings chooser disabled while YouTube import is pending", async () => { + tauriInvoke.mockImplementation(() => new Promise(() => undefined)); + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + fireEvent.change(screen.getByPlaceholderText(/YouTube URL/i), { + target: { value: "https://youtube.com/watch?v=abc123DEF45" } + }); + fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i })); + + const settingsChooseButton = await screen.findByRole("button", { name: /Choose a supported file/i }); + await waitFor(() => expect(settingsChooseButton).toBeDisabled()); + }); + + it("returns from Settings to tonight's rehearsal map after a song is ready", async () => { + mockLoadProject.mockResolvedValueOnce(succeededResult().result); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getAllByRole("button", { name: /See which audio this device can open/i })[0]!); + expect(screen.getByText(/Tonight's map is ready/i)).toBeTruthy(); + expect(screen.queryByText(/Song Timeline/i)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /Open tonight's rehearsal map/i })); + await waitFor(() => { + expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); + }); + expect(document.getElementById("main-content")).toHaveFocus(); + expect(scrollIntoView).toHaveBeenCalled(); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("keeps the Score view disabled until a song is loaded", () => { render(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..aeedc6990 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -45,6 +45,7 @@ import { } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; +import { RehearsalSettings } from "./features/settings/RehearsalSettings"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; import { Button } from "@/components/ui/button"; @@ -54,11 +55,12 @@ import { Toaster } from "@/components/ui/sonner"; const ANALYSIS_POLL_INTERVAL_MS = 250; const MAX_ERROR_DETAIL_LENGTH = 220; +const SOURCE_CONTROLS_FOCUS_ID = "source-controls-choose-audio"; const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:Users|home|var|tmp|private|Volumes)\/[^\s"'<>]+)/g; const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; -type RehearsalView = "workspace" | "score"; +type RehearsalView = "workspace" | "score" | "settings"; const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, @@ -517,7 +519,8 @@ export function App() { return ; }; - const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const currentView: RehearsalView = + activeView === "settings" ? "settings" : jobResult && activeView === "score" ? "score" : "workspace"; /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { @@ -535,6 +538,27 @@ export function App() { setActiveView(view); }; + /** Leave Settings and start the local-audio picker. */ + const handleSettingsChooseAudio = () => { + if (analysisInFlight || isStarting || isImporting) { + return; + } + setActiveView("workspace"); + void handleChooseLocalAudio(); + }; + + /** Leave Settings and show tonight's rehearsal map. */ + const handleSettingsOpenMap = () => { + setActiveView("workspace"); + window.setTimeout(() => { + const main = document.getElementById("main-content"); + if (main instanceof HTMLElement) { + main.scrollIntoView?.({ block: "start" }); + main.focus(); + } + }, 0); + }; + return (
@@ -586,6 +610,21 @@ export function App() { ); })} +
@@ -613,11 +652,15 @@ export function App() {
@@ -635,7 +678,7 @@ export function App() {
-
+
@@ -681,6 +739,7 @@ export function App() {
+
+ +
+
+
+

{t("localFirstDetail")}

+
+ + + ); +} diff --git a/apps/desktop/src/features/settings/index.tsx b/apps/desktop/src/features/settings/index.tsx index b524e0d30..ea724f8b7 100644 --- a/apps/desktop/src/features/settings/index.tsx +++ b/apps/desktop/src/features/settings/index.tsx @@ -1,50 +1,2 @@ -import { SUPPORTED_AUDIO_FORMATS } from "@bandscope/shared-types"; - -/** Documented. */ -export function SettingsFeature(props: { title: string }) { - const { title } = props; - - return ( -
-

{title}

-
-
-

Supported Audio Formats

-
- {SUPPORTED_AUDIO_FORMATS.map((format) => ( - - .{format} - - ))} -
-
- -
-

Analysis Pipeline

-
    -
  • Decode audio source
  • -
  • Draft section and role extraction
  • -
  • Separate stems by category
  • -
  • Persist analysis results
  • -
-
- -
-

About

-

- BandScope is a local-first rehearsal prep tool. All analysis runs on your device. -

-
-
-
- ); -} +export { RehearsalSettings } from "./RehearsalSettings"; +export { admittedAudioFormats, settingsNextAction } from "./settingsModel"; \ No newline at end of file diff --git a/apps/desktop/src/features/settings/rehearsalSettings.test.ts b/apps/desktop/src/features/settings/rehearsalSettings.test.ts new file mode 100644 index 000000000..6ba23e75c --- /dev/null +++ b/apps/desktop/src/features/settings/rehearsalSettings.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { admittedAudioFormats, settingsNextAction } from "./settingsModel"; + +describe("admittedAudioFormats", () => { + it("keeps the canonical local-audio extensions in first-seen order", () => { + expect(admittedAudioFormats(["wav", "mp3", "flac", "m4a"])).toEqual(["wav", "mp3", "flac", "m4a"]); + }); + + it("normalizes case and spacing without inventing formats", () => { + expect(admittedAudioFormats([" WAV ", "Mp3", "FLAC", "m4A"])).toEqual(["wav", "mp3", "flac", "m4a"]); + }); + + it("drops duplicates after normalization", () => { + expect(admittedAudioFormats(["wav", "WAV", " mp3 ", "mp3"])).toEqual(["wav", "mp3"]); + }); + + it("fail-closes untrusted values instead of naming them as playable formats", () => { + expect( + admittedAudioFormats([ + "aac", + "ogg", + "wav.exe", + "../mp3", + "mp3\nwav", + "", + " ", + 1, + null, + undefined, + { format: "wav" }, + ["wav"] + ]) + ).toEqual([]); + }); + + it("admits only trusted members from a mixed untrusted list", () => { + expect(admittedAudioFormats(["nope", "wav", 12, "m4a", "webm"])).toEqual(["wav", "m4a"]); + }); +}); + +describe("settingsNextAction", () => { + it("asks for a supported file before a song is ready", () => { + expect(settingsNextAction(false)).toBe("choose-audio"); + }); + + it("opens tonight's rehearsal map once a song is ready", () => { + expect(settingsNextAction(true)).toBe("open-map"); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/settings/settingsModel.ts b/apps/desktop/src/features/settings/settingsModel.ts new file mode 100644 index 000000000..fe0624294 --- /dev/null +++ b/apps/desktop/src/features/settings/settingsModel.ts @@ -0,0 +1,31 @@ +const ADMITTED_AUDIO_FORMAT = /^(wav|mp3|flac|m4a)$/; + +/** Next Settings action named from whether tonight's song is ready. */ +export type SettingsNextAction = "choose-audio" | "open-map"; + +/** Return lowercase audio extensions that this Settings surface may name. */ +export function admittedAudioFormats(formats: readonly unknown[]): string[] { + const admitted: string[] = []; + const seen = new Set(); + + for (const format of formats) { + if (typeof format !== "string") { + continue; + } + + const normalized = format.trim().toLowerCase(); + if (!ADMITTED_AUDIO_FORMAT.test(normalized) || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + admitted.push(normalized); + } + + return admitted; +} + +/** Name the next Settings action from whether tonight's song is already ready. */ +export function settingsNextAction(songReady: boolean): SettingsNextAction { + return songReady ? "open-map" : "choose-audio"; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..336104bf5 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -109,6 +109,16 @@ "compactViewSuffix": "compact view", "comingSoon": "Coming soon", "settingsComingSoon": "Settings coming soon", + "settingsOpenHint": "See which audio this device can open", + "settingsViewEyebrow": "Local-first settings", + "settingsViewTitle": "Tonight's audio", + "settingsViewSubtitle": "Analysis stays on this device. Pick a file this app can open.", + "settingsFormatsLabel": "Audio this device can open", + "settingsFormatsMissing": "No trusted audio formats are available yet. Choose local audio from the source bar.", + "settingsChooseAudioAction": "Choose a supported audio file on this device to start tonight's rehearsal.", + "settingsChooseAudioButton": "Choose a supported file", + "settingsOpenMapAction": "Tonight's map is ready. Open the rehearsal map and start the first section.", + "settingsOpenMapButton": "Open tonight's rehearsal map", "helpComingSoon": "Help coming soon", "localFirst": "Local-first", "localFirstDetail": "Your rehearsal map stays on this device. Project files stay local. YouTube only leaves the app when you choose import.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..7d25b0c4e 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -109,6 +109,16 @@ "compactViewSuffix": "간단 보기", "comingSoon": "곧 제공됩니다", "settingsComingSoon": "설정은 곧 제공됩니다", + "settingsOpenHint": "이 기기에서 열 수 있는 오디오를 보세요", + "settingsViewEyebrow": "로컬 우선 설정", + "settingsViewTitle": "오늘 쓸 오디오", + "settingsViewSubtitle": "분석은 이 기기에서 진행됩니다. 이 앱이 열 수 있는 파일을 고르세요.", + "settingsFormatsLabel": "이 기기에서 열 수 있는 오디오", + "settingsFormatsMissing": "아직 신뢰할 오디오 형식이 없습니다. 소스 막대에서 로컬 오디오를 고르세요.", + "settingsChooseAudioAction": "오늘 합주를 시작하려면 이 기기에서 지원되는 오디오 파일을 고르세요.", + "settingsChooseAudioButton": "지원되는 파일을 고르세요", + "settingsOpenMapAction": "오늘 합주 지도가 준비됐습니다. 지도를 열고 첫 구간부터 시작하세요.", + "settingsOpenMapButton": "오늘 합주 지도를 여세요", "helpComingSoon": "도움말은 곧 제공됩니다", "localFirst": "로컬 우선", "localFirstDetail": "합주 지도는 이 기기에 머뭅니다. 프로젝트 파일은 로컬에 저장됩니다. 유튜브는 가져오기를 선택할 때만 앱 밖으로 나갑니다.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..93a306f49 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/settings/settingsModel.ts", + "src/features/settings/RehearsalSettings.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..760163bc3 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -17,7 +17,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Button / Destructive | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-250 | `apps/desktop/src/components/ui/button.tsx` | Destructive or high-risk actions with `variant="destructive"`. | | Button / Link | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-287 | `apps/desktop/src/components/ui/button.tsx` | Inline actions with `variant="link"` and usually `size="sm"`. | | Button / Source | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-324 | `apps/desktop/src/components/ui/button.tsx` | Design pattern only. Runtime uses `variant="secondary"` or `variant="outline"` plus source-control `className`; no dedicated source Button variant exists yet. | -| Icon Button | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-407 | `apps/desktop/src/components/ui/button.tsx` | Icon-only actions require `aria-label`; use `size="icon-sm"`, `icon`, or `icon-lg`. | +| Icon Button | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-407 | `apps/desktop/src/App.tsx`, `apps/desktop/src/components/ui/button.tsx` | Icon-only actions require `aria-label`; use `size="icon-sm"`, `icon`, or `icon-lg`. The Settings control is enabled and names the next audio action. | | Input | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-471 | `apps/desktop/src/components/ui/input.tsx` | Text, URL, and file inputs; use native `type`, `placeholder`, `disabled`, and `aria-invalid`. | | Badge | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-494 | `apps/desktop/src/components/ui/badge.tsx` | Compact metadata with `variant="default"`, `secondary`, `destructive`, `outline`, `ghost`, or `link`. | | Tabs Trigger | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-517 | `apps/desktop/src/components/ui/tabs.tsx` | Pair `TabsList variant="default" | "line"` with `TabsTrigger`; do not render triggers outside a `Tabs` root. |