web-player: buttons (Button, IconButton, PlayButton)

Button: subtle variants (primary/secondary, left unchanged per the
user's call), glass (formerly secondaryOnGlow, with its own
font-normal), accent, strong (cta-strong/ink, replacing the `!`
overrides on the Home hero), danger. Uses the --color-btn* tokens
instead of bg-[var(--mf-btn-...)]. Radius unified to rounded-control.

IconButton: adds shape (square/round), size (xs/sm/md/lg), tone
(muted/subtle/plain), pressed (aria-pressed + text-accent),
href/reload (links), surface (permanent background + hover to
surface-hover instead of the generic `hover` alias, which matched
surface-2 and gave no contrast).

New PlayButton: a single place for the play/pause SVGs, strong/glass
variants, as="span" for non-interactive overlays. Replaces
TransportControls' main button, the MixTile overlay, and the "Top
result" circle in Explore.

Migrated to IconButton: close queue, "Create playlist" (Sidebar),
Like (TrackInfo, with pressed), queue (PlayerExtras), back (TopBar),
login (MobileHeader), and the row actions in tables (remove/add, size
xs). BackLink uses size-icon-sm and drops a duplicate hover.

Decision (not blocking): remove TopBar's decorative "forward" button
and PlayerExtras' decorative AlignJustify icon (visually dead code).
TransportControls' buttons (shuffle/prev/next/repeat) are
deliberately left unmigrated: they currently have no background or
hit-box of their own, and IconButton always applies a fixed-size
box — migrating them would have added a visible hover pill that
couldn't be verified without Docker this session.

Verified with npm run check/build.
This commit is contained in:
2026-09-20 22:48:08 +02:00
parent a4ca1f32d3
commit aa20e70671
20 changed files with 191 additions and 138 deletions
@@ -24,10 +24,10 @@
<a
href={target.url}
{onclick}
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
<span class="grid size-8 place-items-center">
<ArrowLeft class="size-icon-sm" />
</span>
{target.label}
</a>
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'secondaryOnGlow' | 'accent' | 'danger';
variant?: 'primary' | 'secondary' | 'glass' | 'accent' | 'strong' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
@@ -34,21 +34,20 @@
lg: 'px-6 py-3.5 text-sm'
};
const subtleButton =
'bg-[var(--mf-btn-bg)] text-fg-2 hover:bg-[var(--mf-btn-bg-hover)] hover:text-fg';
const subtleButtonOnGlow =
'bg-[var(--mf-btn-secondary-bg)] text-fg-2 hover:bg-[var(--mf-btn-secondary-bg-hover)] hover:text-fg';
const subtle = 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg';
const glass = 'font-normal bg-btn-glass text-fg-2 hover:bg-btn-glass-hover hover:text-fg';
const variants = {
primary: subtleButton,
secondary: subtleButton,
secondaryOnGlow: subtleButtonOnGlow,
primary: subtle,
secondary: subtle,
glass,
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
strong: 'bg-cta-strong text-ink hover:brightness-95',
danger: 'bg-danger text-on-art hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-xl transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
`inline-flex items-center justify-center gap-2 rounded-control transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
@@ -4,8 +4,14 @@
interface Props {
label: string;
type?: 'button' | 'submit';
size?: 'sm' | 'md';
size?: 'xs' | 'sm' | 'md' | 'lg';
shape?: 'square' | 'round';
tone?: 'muted' | 'subtle' | 'plain';
pressed?: boolean;
surface?: boolean;
revealOnHover?: boolean;
href?: string;
reload?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
@@ -14,26 +20,55 @@
let {
label,
type = 'button',
size = 'md',
size = 'sm',
shape = 'square',
tone = 'muted',
pressed,
surface = false,
revealOnHover = false,
href,
reload = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
const SIZE: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'size-7',
sm: 'size-8',
md: 'size-9',
lg: 'size-10'
};
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-muted hover:text-fg',
subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg'
};
const background = $derived(
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
);
const classes = $derived(
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
} ${klass}`
);
</script>
<button
{type}
{onclick}
aria-label={label}
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg focus-visible:bg-hover focus-visible:text-fg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {sizes[
size
]} {revealOnHover
? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100'
: ''} {klass}"
>
{@render children()}
</button>
{#if href}
<a
{href}
data-sveltekit-reload={reload ? '' : undefined}
aria-label={label}
title={label}
class={classes}
>
{@render children()}
</a>
{:else}
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
{@render children()}
</button>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
@@ -36,14 +36,13 @@
<div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<button
type="button"
<PlayButton
variant="glass"
size="sm"
onclick={play}
aria-label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-on-art/12 bg-ink/76 text-fg opacity-0 backdrop-blur transition-opacity duration-150 group-hover/tile:opacity-100"
>
<Play class="h-3.5 w-3.5" fill="currentColor" />
</button>
label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100"
/>
</Artwork>
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title}
@@ -0,0 +1,59 @@
<script lang="ts">
interface Props {
playing?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'strong' | 'glass';
label: string;
onclick?: (event: MouseEvent) => void;
as?: 'button' | 'span';
class?: string;
}
let {
playing = false,
size = 'md',
variant = 'strong',
label,
onclick,
as = 'button',
class: klass = ''
}: Props = $props();
const SIZE: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-9.5',
md: 'size-10',
lg: 'size-11.5'
};
const VARIANT: Record<'strong' | 'glass', string> = {
strong: 'bg-cta-strong text-ink hover:brightness-95',
glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur'
};
const classes = $derived(
`grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}`
);
</script>
{#snippet icon()}
{#if playing}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="9" y="1" width="3" height="13" rx="1" />
</svg>
{:else}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
{/snippet}
{#if as === 'span'}
<span aria-hidden="true" class={classes}>
{@render icon()}
</span>
{:else}
<button type="button" {onclick} aria-label={label} class={classes}>
{@render icon()}
</button>
{/if}