diff --git a/web/src/lib/components/FlagPopover.svelte b/web/src/lib/components/FlagPopover.svelte
new file mode 100644
index 00000000..25dbcf75
--- /dev/null
+++ b/web/src/lib/components/FlagPopover.svelte
@@ -0,0 +1,97 @@
+
+
+
e.stopPropagation()}
+>
+
+ Flag this track as broken
+
+
+
+ {#if error}
+
Couldn't save flag — {error}
+ {/if}
+
+
+
+
+
diff --git a/web/src/lib/components/FlagPopover.test.ts b/web/src/lib/components/FlagPopover.test.ts
new file mode 100644
index 00000000..0bd19363
--- /dev/null
+++ b/web/src/lib/components/FlagPopover.test.ts
@@ -0,0 +1,96 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+import FlagPopover from './FlagPopover.svelte';
+import type { TrackRef } from '$lib/api/types';
+
+const invalidateMock = vi.fn();
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return {
+ ...actual,
+ useQueryClient: () => ({ invalidateQueries: invalidateMock })
+ };
+});
+
+vi.mock('$lib/api/quarantine', () => ({
+ flagTrack: vi.fn().mockResolvedValue({ track_id: 't1', reason: 'bad_rip' })
+}));
+
+import { flagTrack } from '$lib/api/quarantine';
+
+const track: TrackRef = {
+ id: 't1',
+ title: 'Roygbiv',
+ album_id: 'a1',
+ album_title: 'Geogaddi',
+ artist_id: 'ar1',
+ artist_name: 'Boards of Canada',
+ duration_sec: 240,
+ stream_url: '/api/tracks/t1/stream'
+};
+
+afterEach(() => vi.clearAllMocks());
+
+describe('FlagPopover', () => {
+ test('default reason is bad_rip; button reads "Flag" when not initialReason', () => {
+ render(FlagPopover, { props: { track, onClose: vi.fn() } });
+ const select = screen.getByRole('combobox') as HTMLSelectElement;
+ expect(select.value).toBe('bad_rip');
+ expect(screen.getByRole('button', { name: /^flag$/i })).toBeInTheDocument();
+ });
+
+ test('pre-fills reason and notes when initial values are provided; button reads "Update flag"', () => {
+ render(FlagPopover, {
+ props: {
+ track,
+ onClose: vi.fn(),
+ initialReason: 'wrong_tags',
+ initialNotes: 'wrong artist'
+ }
+ });
+ const select = screen.getByRole('combobox') as HTMLSelectElement;
+ expect(select.value).toBe('wrong_tags');
+ const textarea = screen.getByPlaceholderText(/what's wrong/i) as HTMLTextAreaElement;
+ expect(textarea.value).toBe('wrong artist');
+ expect(screen.getByRole('button', { name: /update flag/i })).toBeInTheDocument();
+ });
+
+ test('submits with reason and notes; calls invalidateQueries on success', async () => {
+ const onClose = vi.fn();
+ render(FlagPopover, { props: { track, onClose } });
+ const select = screen.getByRole('combobox');
+ await fireEvent.change(select, { target: { value: 'duplicate' } });
+ const textarea = screen.getByPlaceholderText(/what's wrong/i);
+ await fireEvent.input(textarea, { target: { value: 'same recording' } });
+ await fireEvent.click(screen.getByRole('button', { name: /^flag$/i }));
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(flagTrack).toHaveBeenCalledWith({
+ track_id: 't1',
+ reason: 'duplicate',
+ notes: 'same recording'
+ });
+ expect(invalidateMock).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ test('empty notes are not sent', async () => {
+ render(FlagPopover, { props: { track, onClose: vi.fn() } });
+ await fireEvent.click(screen.getByRole('button', { name: /^flag$/i }));
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(flagTrack).toHaveBeenCalledWith({
+ track_id: 't1',
+ reason: 'bad_rip',
+ notes: undefined
+ });
+ });
+
+ test('cancel button calls onClose without firing flagTrack', async () => {
+ const onClose = vi.fn();
+ render(FlagPopover, { props: { track, onClose } });
+ await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(flagTrack).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte
new file mode 100644
index 00000000..a57629bf
--- /dev/null
+++ b/web/src/lib/components/TrackMenu.svelte
@@ -0,0 +1,65 @@
+
+
+ (menuOpen = false)}
+ onkeydown={(e) => e.key === 'Escape' && closeAll()}
+/>
+
+
+
+
+ {#if menuOpen}
+
e.stopPropagation()}
+ >
+
+
+ {/if}
+
+ {#if popoverOpen}
+
+ {/if}
+
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts
new file mode 100644
index 00000000..267908e9
--- /dev/null
+++ b/web/src/lib/components/TrackMenu.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/svelte';
+import TrackMenu from './TrackMenu.svelte';
+import type { TrackRef } from '$lib/api/types';
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
+});
+
+vi.mock('$lib/api/quarantine', () => ({
+ flagTrack: vi.fn().mockResolvedValue({})
+}));
+
+const track: TrackRef = {
+ id: 't1',
+ title: 'Roygbiv',
+ album_id: 'a1',
+ album_title: 'Geogaddi',
+ artist_id: 'ar1',
+ artist_name: 'Boards of Canada',
+ duration_sec: 240,
+ stream_url: '/api/tracks/t1/stream'
+};
+
+afterEach(() => vi.clearAllMocks());
+
+describe('TrackMenu', () => {
+ test('opens menu on kebab click', async () => {
+ render(TrackMenu, { props: { track } });
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ await fireEvent.click(screen.getByRole('button', { name: /track actions for roygbiv/i }));
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
+ });
+
+ test('Escape key closes menu', async () => {
+ render(TrackMenu, { props: { track } });
+ await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await fireEvent.keyDown(window, { key: 'Escape' });
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ });
+
+ test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
+ render(TrackMenu, { props: { track } });
+ await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
+ await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ expect(screen.getByRole('dialog', { name: /flag this track as broken/i })).toBeInTheDocument();
+ });
+});