playlists(#37): restyle playlist cards (stacked cover + themed scrim) + back button

Design polish from feedback:
- Playlist cards are now a "stack of clips" cover: the first video's thumbnail
  fills a 16:9 tile with two offset/rotated layers peeking behind it (::before/
  ::after). A themed gradient scrim (accent-strong→accent→clear, via color-mix)
  darkens the base so the title sits BOLD and WHITE over the cover instead of
  small grey text underneath. Video count moved to a pill in the top-right.
  channelPlaylists.js resolves the cover from the app's active videos
  (buildVideoAddressPointer → video.thumbnail), falling back to playlist.image.
- Back button on the playlist view: was a bare "← Back to channel" text link, now
  a proper .btn-ghost (sm) pill with a chevron icon — matches the site's buttons
  and is theme-aware.
- tokens.css: added --size-4px/--size-5px for the stack offsets (token lint).

Scrim/title use --color-white (always light — the scrim is always dark
regardless of theme). Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-06 14:18:31 -04:00
1 parent a2e26b00b9
commit f46b1ce7f3
4 files changed
+139 -31

No files matched your search

+73 -23
View File
@@ -2951,34 +2951,62 @@
background-color: currentColor;
}
/* #37: channel playlists section — a responsive row of playlist cards. */
/* #37: channel playlists — each card is a stacked "pile of clips" cover with a
themed gradient scrim and the title set bold over it. */
.playlist-grid {
@apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4;
gap: var(--space-md);
gap: var(--space-lg);
}
.playlist-card {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
position: relative;
display: block;
aspect-ratio: 16 / 9;
text-decoration: none;
transition: transform 0.12s ease-out;
/* leave room for the offset stack layers to peek out */
margin-block-start: var(--space-2xs);
margin-inline-end: var(--space-xs);
transition: transform 0.14s ease-out;
}
/* Two offset layers behind the cover read as a stack of videos. */
.playlist-card::before,
.playlist-card::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--radius-md);
background-color: var(--color-page-alt);
border: var(--border-width-hairline) solid var(--color-overlay-strong);
box-shadow: var(--shadow-sm);
}
.playlist-card::before {
transform: translate(var(--size-5px), calc(-1 * var(--size-5px)))
rotate(2.5deg);
}
.playlist-card::after {
transform: translate(calc(-1 * var(--size-4px)), calc(-1 * var(--size-3px)))
rotate(-2deg);
}
.playlist-card__thumb {
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
box-shadow: var(--shadow-md);
}
.playlist-card:hover {
transform: translateY(-2px);
}
.playlist-card__thumb {
position: relative;
display: flex;
align-items: flex-end;
justify-content: flex-end;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--radius-sm);
background-color: var(--color-page-alt);
border: var(--border-width-hairline) solid var(--color-page-alt);
.playlist-card:hover .playlist-card__thumb {
box-shadow: var(--shadow-lg);
}
.playlist-card__image {
@@ -2989,23 +3017,45 @@
object-fit: cover;
}
/* Themed scrim: accent-tinted dark at the base fading to clear, so the bold
title reads over any cover (always dark, so the title is always white). */
.playlist-card__scrim {
position: absolute;
inset: 0;
background-image: linear-gradient(
to top,
color-mix(in srgb, var(--color-accent-strong) 62%, black) 0%,
color-mix(in srgb, var(--color-accent) 26%, transparent) 45%,
transparent 74%
);
}
.playlist-card__count {
position: relative;
margin: var(--space-2xs);
position: absolute;
z-index: 2;
inset-block-start: var(--space-2xs);
inset-inline-end: var(--space-2xs);
border-radius: var(--radius-sm);
padding-block: var(--space-3xs);
padding-inline: var(--space-2xs);
font-size: var(--font-size-2xs);
font-weight: 500;
color: var(--color-text-strong);
background-color: var(--color-page);
font-weight: 600;
color: var(--color-white);
background-color: var(--color-overlay-strong);
}
.playlist-card__title {
position: absolute;
z-index: 2;
inset-inline: 0;
inset-block-end: 0;
margin: 0;
padding: var(--space-sm);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
font-weight: 700;
line-height: 1.2;
color: var(--color-white);
text-shadow: 0 var(--size-2px) var(--size-3px) var(--color-overlay-black);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
+2
View File
@@ -301,6 +301,8 @@
/* Measurement primitives */
--size-2px: 2px;
--size-3px: 3px;
--size-4px: 4px;
--size-5px: 5px;
--size-12px: 12px;
--size-24px: 24px;
--size-30px: 30px;
+48 -6
View File
@@ -11,6 +11,8 @@
// playlist, instead of needing a manual page reload.
import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js";
import { nostrClient } from "./nostrClientFacade.js";
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
import { FEATURE_PLAYLISTS } from "./constants.js";
import { devLogger } from "./utils/logger.js";
@@ -35,7 +37,38 @@ function playlistHref(playlist) {
return `#${params.toString()}`;
}
function renderPlaylistCard(doc, playlist) {
// coord -> thumbnail URL, from the app's active videos, so a playlist can show
// its first video's thumbnail as the cover.
function buildThumbnailMap() {
const map = new Map();
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate && typeof video.thumbnail === "string" && video.thumbnail) {
map.set(coordinate, video.thumbnail);
}
}
return map;
}
function coverFor(playlist, thumbnails) {
for (const item of playlist.items) {
if (item.type === "a") {
const thumb = thumbnails.get(item.value);
if (thumb) {
return thumb;
}
}
}
return playlist.image || "";
}
function renderPlaylistCard(doc, playlist, coverUrl) {
const card = doc.createElement("a");
card.href = playlistHref(playlist);
card.className = "playlist-card";
@@ -46,27 +79,33 @@ function renderPlaylistCard(doc, playlist) {
const thumb = doc.createElement("div");
thumb.className = "playlist-card__thumb";
if (playlist.image) {
if (coverUrl) {
const img = doc.createElement("img");
img.src = playlist.image;
img.src = coverUrl;
img.alt = "";
img.loading = "lazy";
img.referrerPolicy = "no-referrer";
img.className = "playlist-card__image";
thumb.appendChild(img);
}
const scrim = doc.createElement("div");
scrim.className = "playlist-card__scrim";
thumb.appendChild(scrim);
const count = doc.createElement("span");
count.className = "playlist-card__count";
const n = playlist.items.length;
count.textContent = `${n} ${n === 1 ? "video" : "videos"}`;
thumb.appendChild(count);
card.appendChild(thumb);
const title = doc.createElement("p");
title.className = "playlist-card__title";
title.textContent = playlist.title;
card.appendChild(title);
thumb.appendChild(title);
card.appendChild(thumb);
return card;
}
@@ -111,9 +150,12 @@ async function refresh() {
}
clearChildren(grid);
const thumbnails = buildThumbnailMap();
const frag = doc.createDocumentFragment();
for (const playlist of playlists) {
frag.appendChild(renderPlaylistCard(doc, playlist));
frag.appendChild(
renderPlaylistCard(doc, playlist, coverFor(playlist, thumbnails)),
);
}
grid.appendChild(frag);
section.classList.remove("hidden");
+16 -2
View File
@@ -5,9 +5,23 @@
<a
href="#view=most-recent-videos"
id="playlistBackLink"
class="text-sm text-info-strong hover:underline"
>← Back to channel</a
class="btn-ghost"
data-size="sm"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
aria-hidden="true"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Back to channel
</a>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 id="playlistTitle" class="text-xl font-semibold text-text">Playlist</h2>