mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(ui): visual consistency sweep — shapes, buttons, hero, header alignment (#745)
* fix(ui): square shape across badges, pills and non-player buttons zunoz on Discord flagged inconsistent shapes for play buttons, badges and pills across the app. Unify to var(--radius-sm) for non-player surfaces so the same visual indicator looks identical wherever it appears. Player Bar, Fullscreen Player and Mini Player keep their circular shape as part of the player family; toggle switches, sliders, search input, pagination dots and theme overrides are left alone. Covers: hero play + nav arrows, album-card details button, album header icon buttons, playlist suggestion play, album-row nav arrows; .badge (incl. New / album-detail), genre pill, np chip/tag/badge, np-dash toolbar badge, radio filter chip, radio card chip, mp album plays pill, settings search-result badge, alphabet filter buttons, download hint, mobile search chip, artist release-group count, artist external link, all Orbit session pills, device-sync count badge; ServersTab "Aktiv" badge, PlaylistCard loading badge, AlbumRow / ArtistRow "more" buttons. * fix(composers): collapse empty space between virtual rows The composer grid uses text-only tiles (~78 px intrinsic) but estimateRowHeightPx scaled with cell width like the image variants, clamped to a 200 px maximum. On normal viewports every virtual row reserved ~200 px while the actual card was ~78 px, leaving ~120 px of empty space below each row. Pin the composer variant to min === max so the rowHeight is a fixed 88 px regardless of cell width. * fix(ui): unify secondary action buttons on btn-surface Action rows on the Artist, Album, Tracks, Favorites and Most Played pages mixed btn-ghost (borderless), btn-surface (bordered) and bare .btn (no variant, picked up bordered look in light themes only). Result was per-page and per-theme inconsistency — secondary buttons sometimes had borders, sometimes not. Unify on btn-surface for all secondary actions so the same affordance looks identical across pages and the difference between themes is just border tone, not border presence. - Tracks hero: Enqueue and Reroll buttons - AlbumHeader: Shuffle, Enqueue, Star, Share, Bio, Download and the Offline-cache states - MostPlayed: sort toggle and compilations filter (now matches the Albums page header) * fix(hero): make pagination dots visible on light backdrops The pagination dots used `rgba(255, 255, 255, 0.35)` which disappears against white-dominant cover art and on light themes, even under the hero gradient overlay. zunoz on Discord reported the inactive dots as effectively invisible. Bump inactive dots to 85 % white with a dark outline + drop shadow so they read against any backdrop, and switch the active dot to the accent color so the highlight reads as colour, not just width. Also opaque-fill `.badge` (`var(--accent)` + `--ctp-crust` text) so the hero pills do not disappear against light cover art either — base class previously used `--accent-dim` which is too transparent for badges per the existing badge rule. * fix(tracks): align Browse-all-tracks header with rows The Tracks page header sat outside the scroll container while rows sat inside it, so the scrollbar gutter shrank only the rows and the last header column drifted right of its row data. With OpenDyslexic selected the wider glyphs pushed "Duration" past the viewport edge entirely; zunoz on Discord reported it. Move SongListHeader inside the scroll container with position: sticky so header and rows share the same width budget. Sticky keeps the header visible while scrolling as a side benefit. * test(cardGridLayout): pin composer variant to fixed row height Guards the fix that collapsed the empty space between Composers grid rows. Re-introducing the `cellWidthPx + extra` scaling for composer would silently bring back ~120 px of dead space per virtual row, so the test asserts the fixed 88 px output across a wide cellWidth range. Image variants (artist / album / playlist) get baseline assertions so the composer case is documented as the deliberate exception, not an oversight. * docs(changelog): UI consistency sweep (PR #745)
This commit is contained in:
committed by
GitHub
parent
4214a20ded
commit
3b94368ffa
@@ -630,6 +630,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
||||
|
||||
* The Mainstage row whose title chevron jumps to **`/new-releases`** was labelled **Recently Added** while the sidebar entry and the page itself read **New Releases** — three different names for the same destination. The row title and the matching **Home Customizer** entry now reuse **`sidebar.newReleases`** so the wording lives in exactly one place; the orphan **`home.recent`** key is dropped from all nine locale files.
|
||||
|
||||
### UI — consistency fixes across badges, action buttons, hero and tracks header
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), thanks to zunoz for the report on the Psysonic Discord, PR [#745](https://github.com/Psychotoxical/psysonic/pull/745)**
|
||||
|
||||
* Unified the corner radius on badges, pills and non-player buttons; Player Bar, Fullscreen and Mini Player keep their circular family identity. Secondary action rows on Artist, Album, Tracks, Favorites and Most Played all share the same `btn-surface` treatment so the same affordance reads the same per page and per theme.
|
||||
* Hero pills stay visible against light-toned cover art (opaque fill), and the pagination dots are readable on every backdrop (brighter inactive dot with a dark outline, accent-coloured active dot).
|
||||
* Composers grid no longer reserves ~200 px per virtual row for ~78 px text-only tiles. The Tracks "browse all" header now lives inside the scroll container so columns line up with the rows under wider fonts like **OpenDyslexic**, and the header stays pinned while scrolling.
|
||||
|
||||
## [1.45.0] - 2026-05-04
|
||||
|
||||
## Added
|
||||
|
||||
@@ -329,7 +329,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-surface"
|
||||
onClick={onShuffleAll}
|
||||
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||
>
|
||||
@@ -337,14 +337,14 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-surface"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
@@ -352,7 +352,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-surface"
|
||||
onClick={handleShareAlbum}
|
||||
aria-label={t('albumDetail.shareAlbum')}
|
||||
data-tooltip={t('albumDetail.shareAlbum')}
|
||||
@@ -362,7 +362,7 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<button className="btn btn-surface" id="album-bio-btn" onClick={onBio}>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
)}
|
||||
@@ -376,7 +376,7 @@ export default function AlbumHeader({
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
|
||||
<button className="btn btn-surface" id="album-download-btn" onClick={onDownload}>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
@@ -387,7 +387,7 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn offline-cache-btn--cached"
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
@@ -396,7 +396,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost offline-cache-btn"
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function AlbumRow({
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
|
||||
@@ -184,7 +184,7 @@ export default function AlbumRow({
|
||||
)}
|
||||
{!loadingMore && moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<ArrowRight size={24} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText }: Props)
|
||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
|
||||
{moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<ArrowRight size={24} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
|
||||
|
||||
@@ -185,8 +185,8 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SongListHeader />
|
||||
<div ref={scrollParentRef} className="virtual-song-list-scroll">
|
||||
<SongListHeader />
|
||||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const song = songs[vi.index];
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function PlaylistCard({
|
||||
left: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 999,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex',
|
||||
|
||||
@@ -218,7 +218,7 @@ export function ServersTab({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
|
||||
{isActive && (
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
|
||||
{t('settings.serverActive')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function MostPlayed() {
|
||||
<h1 className="mp-title">{t('mostPlayed.title')}</h1>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost mp-sort-btn"
|
||||
className="btn btn-surface mp-sort-btn"
|
||||
onClick={() => setSortAsc(v => !v)}
|
||||
data-tooltip={sortAsc ? t('mostPlayed.sortMost') : t('mostPlayed.sortLeast')}
|
||||
>
|
||||
@@ -136,7 +136,7 @@ export default function MostPlayed() {
|
||||
<div className="mp-section-header">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||
<button
|
||||
className={`btn btn-ghost mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
|
||||
className={`btn btn-surface mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
|
||||
onClick={() => setFilterCompilations(v => !v)}
|
||||
data-tooltip={t('mostPlayed.filterCompilations')}
|
||||
data-tooltip-pos="left"
|
||||
|
||||
@@ -160,13 +160,13 @@ export default function Tracks() {
|
||||
<Play size={16} fill="currentColor" /> {t('tracks.playSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
className="btn btn-surface"
|
||||
onClick={() => enqueue([songToTrack(hero)])}
|
||||
>
|
||||
<ListPlus size={16} /> {t('tracks.enqueueSong')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-surface"
|
||||
onClick={rerollHero}
|
||||
disabled={heroLoading}
|
||||
aria-label={t('tracks.heroReroll')}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
gap: 5px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
@@ -189,7 +189,7 @@
|
||||
color: var(--ctp-crust);
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.alphabet-filter-btn {
|
||||
min-width: 26px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
/* Konsistenz mit `.album-card-new-badge`: eckig, nicht pill. */
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.album-detail-back {
|
||||
@@ -256,7 +258,7 @@
|
||||
color: var(--text-secondary);
|
||||
background: rgba(203, 166, 247, 0.08);
|
||||
border: 1px solid rgba(203, 166, 247, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px 3px 8px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -743,7 +743,7 @@
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
border: 1px solid color-mix(in srgb, var(--genre-color, var(--accent)) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--genre-color, var(--accent)) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.28em 0.78em;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -37,13 +37,18 @@
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
/* Near-opaque white plus a dark outline so the dot reads against any hero
|
||||
backdrop — including light cover art under the gradient overlay where
|
||||
translucent dots disappear. */
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-dot-active {
|
||||
width: 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45), 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Wrapper that pins prev/next arrows to the hero's left/right edges. */
|
||||
@@ -65,7 +70,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
@@ -161,7 +166,7 @@
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
letter-spacing: 0.03em;
|
||||
@@ -508,7 +508,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
@@ -371,7 +371,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||
white-space: nowrap;
|
||||
@@ -115,7 +115,7 @@
|
||||
trigger fires intermittently. */
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
@@ -203,7 +203,7 @@
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
}
|
||||
@@ -1116,7 +1116,7 @@
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
@@ -1175,7 +1175,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-crust, #11111b);
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.orbit-guest-queue__live-badge svg { animation: orbit-live-pulse 2s ease-in-out infinite; }
|
||||
@keyframes orbit-live-pulse {
|
||||
@@ -1246,7 +1246,7 @@
|
||||
.orbit-guest-queue__count {
|
||||
padding: 1px 6px;
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1309,7 +1309,7 @@
|
||||
padding: 1px 7px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary, #0b0b13);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
@@ -1677,7 +1677,7 @@
|
||||
.because-card-pill {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, inherit);
|
||||
white-space: nowrap;
|
||||
@@ -1700,7 +1700,7 @@
|
||||
color: var(--text-primary, var(--text-secondary, inherit));
|
||||
background: color-mix(in srgb, var(--text-primary) 14%, transparent);
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-shadow: none;
|
||||
}
|
||||
.because-card-meta > span:not(:last-child)::after {
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--text-secondary);
|
||||
color: var(--ctp-surface1);
|
||||
font-size: 10px;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
/* Opaque fill — `--accent-dim` is too transparent and disappears against
|
||||
light-toned hero backdrops when the cover art is white-dominant. */
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -33,7 +33,13 @@
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
/* Header lives inside the scroll container so its width matches the rows'
|
||||
(scrollbar gutter applies to both). Sticky keeps it pinned while scrolling
|
||||
and the opaque background hides rows passing underneath. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeCardGridColumnCount } from './cardGridLayout';
|
||||
import { computeCardGridColumnCount, estimateRowHeightPx } from './cardGridLayout';
|
||||
import { LIBRARY_GRID_MAX_COLUMNS_MAX, LIBRARY_GRID_MAX_COLUMNS_MIN } from '../store/authStoreDefaults';
|
||||
|
||||
describe('estimateRowHeightPx', () => {
|
||||
describe('composer variant', () => {
|
||||
// Composer tiles are text-only — they do not carry imagery that scales
|
||||
// with cell width. Reverting this to `cellWidthPx + extra` would re-open
|
||||
// the "200 px reserved per virtual row vs. ~78 px actual card" gap that
|
||||
// left empty space between every Composers grid row.
|
||||
it('returns a fixed height regardless of cell width', () => {
|
||||
expect(estimateRowHeightPx(40, 'composer')).toBe(88);
|
||||
expect(estimateRowHeightPx(150, 'composer')).toBe(88);
|
||||
expect(estimateRowHeightPx(400, 'composer')).toBe(88);
|
||||
expect(estimateRowHeightPx(2_000, 'composer')).toBe(88);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image variants scale with cell width', () => {
|
||||
it('artist grows roughly linearly with cell width, clamped to its band', () => {
|
||||
expect(estimateRowHeightPx(200, 'artist')).toBe(272);
|
||||
expect(estimateRowHeightPx(50, 'artist')).toBe(200); // min clamp
|
||||
expect(estimateRowHeightPx(1_000, 'artist')).toBe(520); // max clamp
|
||||
});
|
||||
|
||||
it('album grows roughly linearly with cell width, clamped to its band', () => {
|
||||
expect(estimateRowHeightPx(200, 'album')).toBe(308);
|
||||
expect(estimateRowHeightPx(50, 'album')).toBe(260); // min clamp
|
||||
expect(estimateRowHeightPx(1_000, 'album')).toBe(560); // max clamp
|
||||
});
|
||||
|
||||
it('playlist behaves like album', () => {
|
||||
expect(estimateRowHeightPx(200, 'playlist')).toBe(308);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCardGridColumnCount', () => {
|
||||
it('never exceeds the configured max', () => {
|
||||
expect(computeCardGridColumnCount(20_000, 6)).toBe(6);
|
||||
|
||||
@@ -38,8 +38,9 @@ const VARIANT: Record<CardGridRowHeightVariant, { extra: number; min: number; ma
|
||||
/** Cover scales with cell width; ~108px headroom matches prior ~288px row at ~180px tiles. */
|
||||
album: { extra: 108, min: 260, max: 560 },
|
||||
playlist: { extra: 108, min: 260, max: 560 },
|
||||
/** Text-only composer tiles (~78px intrinsic) with some slack for wrapping. */
|
||||
composer: { extra: 56, min: 88, max: 200 },
|
||||
/** Text-only composer tiles: no imagery → fixed intrinsic height, does not
|
||||
* scale with cell width like the image variants. min === max pins it. */
|
||||
composer: { extra: 0, min: 88, max: 88 },
|
||||
};
|
||||
|
||||
export function estimateRowHeightPx(cellWidthPx: number, variant: CardGridRowHeightVariant): number {
|
||||
|
||||
Reference in New Issue
Block a user