Files
minstrel/web/src/lib/components/DiscoverResultCard.svelte
T
bvandeusenandClaude Opus 5 6e39471a70
test-web / test (push) Failing after 37s
android / Build + lint + test (push) Failing after 1m42s
feat(discover): snooze affordance on Android + web suggestion cards — #2375
Completes the snooze from slice 3 (#2374), so it's now touchable on both
clients (rule #27 — the server side alone was never shippable).

Copy is "Not right now" everywhere, never a dislike (rule #101). The
parked list even says so out loud: "Nothing here counts against your
taste profile."

Both clients flip the card in place to a "Not right now" state with an
Undo, rather than yanking it out of the grid under the cursor. The row
leaves on the next refetch; the persistent way back is a parked-list
section below the deck. That list isn't optional garnish — a snoozed
candidate is by definition absent from the deck, so without it the
DELETE endpoint is unreachable.

Android routes the write through the offline MutationQueue per rule #100,
as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state
rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a
queued snooze the user has since undone is dropped unsent instead of
replaying after the undo and re-hiding an artist they asked to see. The
collapse helper is now a pure top-level function so that rule is unit
tested rather than inferred.

The repository does NOT enqueue on a 4xx — a permanent rejection would
replay to the same failure and would raise a misleading "will sync when
online" hint. The common case is a 404 from un-snoozing a row that
already lapsed, which is the user's intended end state anyway.

Also: an empty deck used to have one meaning (no listening signal yet).
It can now also mean "you parked them all", so the empty copy branches —
telling that user to go listen to something would be wrong advice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:03:10 -04:00

191 lines
5.5 KiB
Svelte

<script lang="ts" module>
export type DiscoverCardKind = 'artist' | 'album' | 'track';
// 'snoozed' is a transient state the card flips to in place after the user
// parks it, so the disappearance is legible and undoable rather than a card
// silently vanishing from under the cursor (rule #24). The row is gone on
// the next refetch; the persistent way back is the snoozed list.
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
</script>
<script lang="ts">
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
let {
kind,
title,
subtitle,
imageUrl,
state,
attribution,
onRequest,
onSnooze,
onUnsnooze,
}: {
kind: DiscoverCardKind;
title: string;
subtitle?: string;
imageUrl?: string;
state: DiscoverCardState;
attribution?: string;
onRequest?: () => void;
// Omit both to get a card with no snooze affordance — the Lidarr search
// results reuse this component and have nothing to park.
onSnooze?: () => void;
onUnsnooze?: () => 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}
{#if attribution}
<div class="attribution text-xs italic text-text-secondary" data-testid="attribution">
{attribution}
</div>
{/if}
<div class="badge-row" data-testid="badge-row">
{#if state === 'kept'}
<span class="kept-pill" role="status">Kept</span>
{:else if state === 'snoozed'}
<span class="snoozed-pill" role="status">Not right now</span>
{/if}
</div>
</div>
<div class="actions pt-3" data-testid="actions">
{#if state === 'snoozed'}
<button
type="button"
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
Undo
</button>
{:else if state === 'requestable'}
<div class="flex items-center gap-2">
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
>
<Plus size={16} strokeWidth={1} /> Request
</button>
{#if onSnooze}
<!--
Icon-only to keep Request unambiguously the primary action, with
the intent carried by the accessible name. "Not right now" is the
whole point of the wording: this parks a suggestion, it does not
record an opinion about the artist (rule #101).
-->
<button
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
</button>
{/if}
</div>
{:else if state === 'kept'}
<button
type="button"
disabled
aria-label={`${title} is already in library`}
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
aria-label={`${title} already requested`}
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);
}
/* Muted rather than accented: a parked card should recede, not compete
with the live suggestions around it. Same geometry as .kept-pill so the
two read as one component in different states. */
.snoozed-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: var(--fs-slate);
color: var(--fs-vellum);
}
.card[data-state='snoozed'] {
opacity: 0.6;
}
</style>