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
co-authored by Claude Opus 4.7
parent ce7424219c
commit c8bd19d6f1
4 changed files with 256 additions and 2 deletions
@@ -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>