feat(web): add DiscoverResultCard with reserved badge slot + anchored button

T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:

  - Flex column with `margin-top: auto` on `.actions`, so the action
    button is anchored to the bottom of the card body regardless of
    title/subtitle wrap differences.
  - `.badge-row` always rendered with `min-height: 22px`, so the
    title baseline holds even when no Kept pill is present.

Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.

State -> action mapping (sentence case per FabledSword voice):
  requestable -> bg-action-primary "Request" with Plus icon
  kept        -> disabled ghost "In library" + Kept pill
  requested   -> disabled ghost "Requested"

Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 20:27:58 -04:00
parent ce7424219c
commit c8bd19d6f1
4 changed files with 256 additions and 2 deletions
+11 -1
View File
@@ -8,7 +8,8 @@
"name": "minstrel-web",
"version": "0.0.0",
"dependencies": {
"@tanstack/svelte-query": "^5.90.2"
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
@@ -2637,6 +2638,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2 -1
View File
@@ -28,6 +28,7 @@
"vitest": "^2.1.4"
},
"dependencies": {
"@tanstack/svelte-query": "^5.90.2"
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
}
}
@@ -0,0 +1,128 @@
<script lang="ts" module>
export type DiscoverCardKind = 'artist' | 'album' | 'track';
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
</script>
<script lang="ts">
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
let {
kind,
title,
subtitle,
imageUrl,
state,
onRequest,
}: {
kind: DiscoverCardKind;
title: string;
subtitle?: string;
imageUrl?: string;
state: DiscoverCardState;
onRequest?: () => void;
} = $props();
const FallbackIcon = $derived(
kind === 'artist' ? Disc3 : kind === 'album' ? Album : Music2
);
function handleRequest() {
if (state === 'requestable') onRequest?.();
}
</script>
<article
class="card rounded-lg border border-border bg-surface p-3"
data-state={state}
>
<div
class="art flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-surface-hover"
>
{#if imageUrl}
<img
src={imageUrl}
alt=""
loading="lazy"
class="h-full w-full object-cover"
/>
{:else}
<FallbackIcon size={32} strokeWidth={1} class="text-text-muted" />
{/if}
</div>
<div class="text mt-3">
<div class="title text-base font-medium text-text-primary">{title}</div>
{#if subtitle}
<div class="subtitle text-sm text-text-secondary">{subtitle}</div>
{/if}
<div
class="badge-row"
data-testid="badge-row"
style="min-height: 22px;"
>
{#if state === 'kept'}
<span class="kept-pill">Kept</span>
{/if}
</div>
</div>
<div class="actions pt-3" data-testid="actions" style="margin-top: auto;">
{#if state === 'requestable'}
<button
type="button"
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
onclick={handleRequest}
>
<Plus size={16} strokeWidth={1} /> Request
</button>
{:else if state === 'kept'}
<button
type="button"
disabled
onclick={handleRequest}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
>
In library
</button>
{:else}
<button
type="button"
disabled
onclick={handleRequest}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
>
Requested
</button>
{/if}
</div>
</article>
<style>
.card {
display: flex;
flex-direction: column;
}
.text {
display: flex;
flex-direction: column;
gap: 4px;
}
.badge-row {
min-height: 22px;
display: flex;
align-items: center;
}
.actions {
margin-top: auto;
}
.kept-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
</style>
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import DiscoverResultCard from './DiscoverResultCard.svelte';
afterEach(() => vi.clearAllMocks());
describe('DiscoverResultCard', () => {
test('requestable state renders Request button and calls onRequest on click', async () => {
const onRequest = vi.fn();
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Boards of Canada',
state: 'requestable',
onRequest,
},
});
const btn = screen.getByRole('button', { name: /request/i });
expect(btn).not.toBeDisabled();
await fireEvent.click(btn);
expect(onRequest).toHaveBeenCalledOnce();
});
test('kept state renders disabled "In library" button + Kept pill', () => {
render(DiscoverResultCard, {
props: {
kind: 'album',
title: 'Music Has The Right To Children',
state: 'kept',
},
});
expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled();
expect(screen.getByText(/kept/i)).toBeInTheDocument();
});
test('requested state renders disabled "Requested" button', () => {
render(DiscoverResultCard, {
props: {
kind: 'track',
title: 'Roygbiv',
state: 'requested',
},
});
expect(screen.getByRole('button', { name: /requested/i })).toBeDisabled();
});
test('does not call onRequest when state is not requestable', async () => {
const onRequest = vi.fn();
render(DiscoverResultCard, {
props: {
kind: 'album',
title: 'Geogaddi',
state: 'kept',
onRequest,
},
});
// Button is disabled — the test still verifies onRequest isn't called even
// if a click slips through (jsdom does not enforce :disabled at fireEvent level).
const btn = screen.getByRole('button');
await fireEvent.click(btn);
expect(onRequest).not.toHaveBeenCalled();
});
test('badge row reserves min-height: 22px even when empty', () => {
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Aphex Twin',
state: 'requestable',
},
});
const row = screen.getByTestId('badge-row');
const cs = getComputedStyle(row);
expect(cs.minHeight).toBe('22px');
});
test('actions block is anchored to bottom (margin-top: auto)', () => {
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Aphex Twin',
state: 'requestable',
},
});
const actions = screen.getByTestId('actions');
expect(getComputedStyle(actions).marginTop).toBe('auto');
});
test('renders <img> when imageUrl is set', () => {
const { container } = render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'X',
state: 'requestable',
imageUrl: 'https://example.com/x.jpg',
},
});
const img = container.querySelector('img') as HTMLImageElement;
expect(img).toBeInTheDocument();
expect(img.src).toBe('https://example.com/x.jpg');
});
test('renders fallback Lucide glyph (no <img>) when imageUrl is absent', () => {
const { container } = render(DiscoverResultCard, {
props: {
kind: 'album',
title: 'X',
state: 'requestable',
},
});
expect(container.querySelector('img')).not.toBeInTheDocument();
// Lucide renders an inline <svg>; verify its presence as a proxy for "fallback rendered"
expect(container.querySelector('svg')).toBeInTheDocument();
});
});