Wishlist Nebula — artist orb visualization replacing category cards

Bespoke wishlist page design: each artist is a glowing orb sized by
track count (sm/md/lg), with a spinning conic gradient ring colored by
artist name hash. Click to expand — albums appear as satellite rows
with cover art, singles as compact pills. Remove buttons on hover at
every level (album, single track).

Search bar filters orbs in real-time. Download Albums/Singles buttons
trigger the existing category download flow. Orbs sorted by track count
(biggest artists first). Responsive layout with mobile breakpoints.
This commit is contained in:
Broque Thomas 2026-04-16 16:24:56 -07:00
parent cd157fc692
commit dd26437125
3 changed files with 238 additions and 97 deletions

View file

@ -6772,32 +6772,32 @@
</div>
</div>
<!-- Category grid -->
<div class="wishlist-category-grid" id="wishlist-page-categories">
<div class="wishlist-category-card" data-category="albums" onclick="selectWishlistCategory('albums')">
<div class="wishlist-mosaic-bg" id="wishlist-page-albums-mosaic"></div>
<div class="wishlist-category-content">
<div class="wishlist-category-icon">&#128191;</div>
<div class="wishlist-category-title">Albums / EPs</div>
<div class="wishlist-category-count" id="wishlist-page-albums-count">0 tracks</div>
<div class="wishlist-category-badge" id="wishlist-page-albums-badge" style="display: none;">Next in Queue</div>
<!-- Nebula view — artist orbs with album satellites -->
<div class="wl-nebula" id="wishlist-nebula">
<!-- Action bar -->
<div class="wl-nebula-bar">
<input type="text" class="wl-nebula-search" id="wl-nebula-search" placeholder="Search artists, albums..." oninput="_filterNebula()">
<div class="wl-nebula-bar-actions">
<button class="watchlist-action-btn watchlist-action-primary" onclick="selectWishlistCategory('albums')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Albums
</button>
<button class="watchlist-action-btn watchlist-action-secondary" onclick="selectWishlistCategory('singles')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Singles
</button>
</div>
</div>
<div class="wishlist-category-card" data-category="singles" onclick="selectWishlistCategory('singles')">
<div class="wishlist-mosaic-bg" id="wishlist-page-singles-mosaic"></div>
<div class="wishlist-category-content">
<div class="wishlist-category-icon">&#127925;</div>
<div class="wishlist-category-title">Singles</div>
<div class="wishlist-category-count" id="wishlist-page-singles-count">0 tracks</div>
<div class="wishlist-category-badge" id="wishlist-page-singles-badge" style="display: none;">Next in Queue</div>
</div>
<!-- Artist orbs container -->
<div class="wl-nebula-field" id="wl-nebula-field">
<!-- Populated by JS: artist orbs with album fans -->
</div>
</div>
<!-- Track list (hidden until category selected) -->
<!-- Legacy track list (used by selectWishlistCategory for download flow) -->
<div id="wishlist-category-tracks" class="wishlist-category-tracks" style="display: none;">
<div class="wishlist-category-header">
<button class="wishlist-back-btn" onclick="backToCategories()">&#8592; Back</button>
<button class="wishlist-back-btn" onclick="_nebulaBack()">&#8592; Back</button>
<span id="wishlist-category-name" class="wishlist-category-name"></span>
<div class="wishlist-category-header-right">
<button class="wishlist-select-all-btn" id="wishlist-select-all-btn" onclick="toggleWishlistSelectAll()">Select All</button>
@ -6806,10 +6806,6 @@
</button>
</div>
</div>
<div class="wishlist-track-search-container">
<svg class="wishlist-track-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="rgba(255,255,255,0.35)"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="wishlist-track-search-input" class="wishlist-track-search-input" placeholder="Filter tracks..." oninput="filterWishlistTracks()">
</div>
<div class="wishlist-batch-bar" id="wishlist-batch-bar" style="display: none;">
<span class="wishlist-batch-count" id="wishlist-batch-count">0 selected</span>
<button class="watchlist-action-btn watchlist-action-secondary wishlist-batch-remove-btn" onclick="batchRemoveFromWishlist()">

View file

@ -13851,11 +13851,11 @@ async function selectWishlistCategory(category) {
const tracksList = document.getElementById('wishlist-tracks-list');
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const categoryGrid = document.querySelector('.wishlist-category-grid');
const nebulaEl = document.getElementById('wishlist-nebula');
const downloadBtn = document.getElementById('wishlist-download-btn');
const categoryName = document.getElementById('wishlist-category-name');
categoryGrid.style.display = 'none';
if (nebulaEl) nebulaEl.style.display = 'none';
categoryTracksSection.style.display = 'block';
downloadBtn.style.display = 'inline-block';
categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles';
@ -14131,18 +14131,7 @@ function _attachWishlistDelegation(container) {
}
function backToCategories() {
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const categoryGrid = document.querySelector('.wishlist-category-grid');
const downloadBtn = document.getElementById('wishlist-download-btn');
const batchBar = document.getElementById('wishlist-batch-bar');
const trackSearch = document.getElementById('wishlist-track-search-input');
categoryTracksSection.style.display = 'none';
categoryGrid.style.display = 'grid';
downloadBtn.style.display = 'none';
if (batchBar) batchBar.style.display = 'none';
if (trackSearch) trackSearch.value = '';
window.selectedWishlistCategory = null;
_nebulaBack();
}
function toggleAlbumTracks(albumId) {
@ -40496,94 +40485,47 @@ async function initializeWatchlistPage() {
async function initializeWishlistPage() {
try {
const emptyEl = document.getElementById('wishlist-page-empty');
const categoriesEl = document.getElementById('wishlist-page-categories');
const nebulaEl = document.getElementById('wishlist-nebula');
const countEl = document.getElementById('wishlist-page-count');
const tracksSection = document.getElementById('wishlist-category-tracks');
const statsStrip = document.getElementById('wishlist-stats-strip');
// Fetch stats and cycle
const [statsRes, cycleRes] = await Promise.all([
const [statsRes, cycleRes, albumRes, singleRes] = await Promise.all([
fetch('/api/wishlist/stats').then(r => r.json()),
fetch('/api/wishlist/cycle').then(r => r.json()),
fetch('/api/wishlist/tracks?category=albums').then(r => r.json()),
fetch('/api/wishlist/tracks?category=singles').then(r => r.json()),
]);
const { singles = 0, albums = 0, total = 0 } = statsRes;
const currentCycle = cycleRes.cycle || 'albums';
// Update count
if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`;
// Update nav badge
const navBadge = document.getElementById('wishlist-nav-badge');
if (navBadge) {
navBadge.textContent = total;
navBadge.classList.toggle('hidden', total === 0);
}
if (navBadge) { navBadge.textContent = total; navBadge.classList.toggle('hidden', total === 0); }
// Stats strip
const statAlbums = document.getElementById('wishlist-stat-albums');
const statSingles = document.getElementById('wishlist-stat-singles');
const statCycle = document.getElementById('wishlist-stat-cycle');
const statsStrip = document.getElementById('wishlist-stats-strip');
if (statAlbums) statAlbums.textContent = albums;
if (statSingles) statSingles.textContent = singles;
if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
// Empty state
if (total === 0) {
if (emptyEl) emptyEl.style.display = '';
if (categoriesEl) categoriesEl.style.display = 'none';
if (nebulaEl) nebulaEl.style.display = 'none';
if (tracksSection) tracksSection.style.display = 'none';
if (statsStrip) statsStrip.style.display = 'none';
wishlistPageState.isInitialized = true;
return;
}
if (emptyEl) emptyEl.style.display = 'none';
if (categoriesEl) categoriesEl.style.display = '';
if (nebulaEl) nebulaEl.style.display = '';
if (tracksSection) tracksSection.style.display = 'none';
if (statsStrip) statsStrip.style.display = '';
// Reset to category view
if (tracksSection) tracksSection.style.display = 'none';
if (categoriesEl) categoriesEl.style.display = '';
// Update category cards
const albumsCountEl = document.getElementById('wishlist-page-albums-count');
const singlesCountEl = document.getElementById('wishlist-page-singles-count');
const albumsBadge = document.getElementById('wishlist-page-albums-badge');
const singlesBadge = document.getElementById('wishlist-page-singles-badge');
if (albumsCountEl) albumsCountEl.textContent = `${albums} tracks`;
if (singlesCountEl) singlesCountEl.textContent = `${singles} tracks`;
if (albumsBadge) albumsBadge.style.display = currentCycle === 'albums' ? '' : 'none';
if (singlesBadge) singlesBadge.style.display = currentCycle === 'singles' ? '' : 'none';
// Add/remove next-in-queue class
const albumCard = categoriesEl.querySelector('[data-category="albums"]');
const singleCard = categoriesEl.querySelector('[data-category="singles"]');
if (albumCard) albumCard.classList.toggle('next-in-queue', currentCycle === 'albums');
if (singleCard) singleCard.classList.toggle('next-in-queue', currentCycle === 'singles');
// Load mosaic covers
try {
const [albumTracksData, singleTracksData] = await Promise.all([
fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json()),
fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json()),
]);
const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20);
const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20);
const albumsMosaic = document.getElementById('wishlist-page-albums-mosaic');
const singlesMosaic = document.getElementById('wishlist-page-singles-mosaic');
if (albumsMosaic) albumsMosaic.innerHTML = generateMosaicBackground(albumCovers);
if (singlesMosaic) singlesMosaic.innerHTML = generateMosaicBackground(singleCovers);
} catch (e) {
console.debug('Could not load mosaic covers:', e);
}
// Start countdown timer
const nextRunSeconds = statsRes.next_run_in_seconds || 0;
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
startWishlistCountdownTimer(currentCycle, nextRunSeconds);
_renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || []);
startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0);
wishlistPageState.isInitialized = true;
} catch (error) {
@ -40592,6 +40534,134 @@ async function initializeWishlistPage() {
}
}
/*
WISHLIST NEBULA Artist orbs with album/single satellites
*/
function _renderWishlistNebula(albumTracks, singleTracks) {
const field = document.getElementById('wl-nebula-field');
if (!field) return;
const artistMap = new Map();
function _parse(track, type) {
let sd = track.spotify_data;
if (typeof sd === 'string') { try { sd = JSON.parse(sd); } catch (e) { return null; } }
if (!sd) return null;
const raw = sd.album;
const albumName = (typeof raw === 'string' ? raw : raw?.name) || 'Unknown';
const albumImage = (typeof raw === 'object' && raw?.images?.[0]?.url) || '';
let artist = 'Unknown Artist';
if (sd.artists?.[0]?.name) artist = sd.artists[0].name;
else if (typeof sd.artists?.[0] === 'string') artist = sd.artists[0];
return { track: sd.name || 'Unknown', artist, album: albumName, image: albumImage, type, id: track.spotify_track_id || track.id || '' };
}
for (const t of albumTracks) { const p = _parse(t, 'album'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); const a = artistMap.get(p.artist); if (!a.albums.has(p.album)) a.albums.set(p.album, { image: p.image, tracks: [] }); a.albums.get(p.album).tracks.push(p); } }
for (const t of singleTracks) { const p = _parse(t, 'single'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); artistMap.get(p.artist).singles.push(p); } }
if (artistMap.size === 0) { field.innerHTML = '<div class="wl-nebula-empty">Your wishlist is empty</div>'; return; }
const sorted = [...artistMap.entries()].sort((a, b) => {
const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length;
const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length;
return bc - ac;
});
function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; }
let html = '';
for (const [name, data] of sorted) {
const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length;
const hue = _hue(name);
const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm';
let img = '';
for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } }
if (!img && data.singles.length) img = data.singles[0].image || '';
html += `<div class="wl-orb-group" data-artist="${escapeHtml(name)}">`;
html += `<div class="wl-orb ${sz}" style="--orb-hue:${hue}" onclick="_toggleOrbExpand(this)">`;
html += `<div class="wl-orb-glow"></div>`;
html += img ? `<img class="wl-orb-img" src="${img}" alt="" loading="lazy">` : `<div class="wl-orb-initials">${escapeHtml(name.substring(0, 2).toUpperCase())}</div>`;
html += `<div class="wl-orb-ring"></div>`;
html += `</div>`;
html += `<div class="wl-orb-label">${escapeHtml(name)}</div>`;
html += `<div class="wl-orb-meta">${total} track${total !== 1 ? 's' : ''}</div>`;
html += `<div class="wl-orb-expanded">`;
if (data.albums.size > 0) {
html += `<div class="wl-orb-albums">`;
for (const [an, ad] of data.albums) {
html += `<div class="wl-satellite" data-album="${escapeHtml(an)}">`;
html += `<div class="wl-satellite-art">${ad.image ? `<img src="${ad.image}" alt="">` : ''}</div>`;
html += `<div class="wl-satellite-info"><div class="wl-satellite-name">${escapeHtml(an)}</div><div class="wl-satellite-count">${ad.tracks.length} tracks</div></div>`;
html += `<button class="wl-satellite-remove" onclick="event.stopPropagation();_removeWishlistAlbum('${escapeHtml(an)}')" title="Remove">&#10005;</button>`;
html += `</div>`;
}
html += `</div>`;
}
if (data.singles.length > 0) {
html += `<div class="wl-orb-singles">`;
for (const s of data.singles) {
html += `<div class="wl-single-pill" data-track-id="${escapeHtml(s.id)}">`;
if (s.image) html += `<img class="wl-pill-art" src="${s.image}" alt="">`;
html += `<span class="wl-pill-name">${escapeHtml(s.track)}</span>`;
html += `<button class="wl-pill-remove" onclick="event.stopPropagation();_removeWishlistTrack('${escapeHtml(s.id)}')" title="Remove">&#10005;</button>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div></div>`;
}
field.innerHTML = html;
}
function _toggleOrbExpand(el) {
const g = el.closest('.wl-orb-group');
if (!g) return;
const was = g.classList.contains('expanded');
document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded'));
if (!was) g.classList.add('expanded');
}
async function _removeWishlistAlbum(albumName) {
if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return;
try {
const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) });
const data = await res.json();
if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); }
else showToast(data.error || 'Failed', 'error');
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function _removeWishlistTrack(trackId) {
try {
const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) });
const data = await res.json();
if (data.success) { const p = document.querySelector(`.wl-single-pill[data-track-id="${trackId}"]`); if (p) p.remove(); showToast('Removed', 'success'); await updateWishlistCount(); }
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
function _filterNebula() {
const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim();
document.querySelectorAll('.wl-orb-group').forEach(g => {
const a = (g.dataset.artist || '').toLowerCase();
const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase());
const match = !q || a.includes(q) || albums.some(al => al.includes(q));
g.style.display = match ? '' : 'none';
if (!match) g.classList.remove('expanded');
});
}
function _nebulaBack() {
const t = document.getElementById('wishlist-category-tracks');
const n = document.getElementById('wishlist-nebula');
if (t) t.style.display = 'none';
if (n) n.style.display = '';
window.selectedWishlistCategory = null;
wishlistPageState.isInitialized = false;
initializeWishlistPage();
}
/**
* Sort the watchlist artist grid by the selected criteria.
*/

View file

@ -57217,8 +57217,83 @@ body.reduce-effects *::after {
color: rgba(255, 255, 255, 0.3);
}
/* ── Category cards upgrade ── */
#wishlist-page-categories {
/*
WISHLIST NEBULA
*/
.wl-nebula { position: relative; }
.wl-nebula-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.wl-nebula-search { flex: 1; min-width: 200px; padding: 10px 16px; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; background: rgba(255,255,255,0.03); color: #fff; font-size: 13px; transition: all 0.2s; box-sizing: border-box; }
.wl-nebula-search:focus { border-color: rgba(var(--accent-rgb),0.4); background: rgba(255,255,255,0.05); box-shadow: 0 0 12px rgba(var(--accent-rgb),0.1); outline: none; }
.wl-nebula-search::placeholder { color: rgba(255,255,255,0.25); }
.wl-nebula-bar-actions { display: flex; gap: 8px; }
.wl-nebula-field { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; padding: 10px 0 30px; }
.wl-nebula-empty { width: 100%; text-align: center; padding: 60px 20px; color: rgba(255,255,255,0.2); font-size: 14px; }
/* ── Orb group ── */
.wl-orb-group { display: flex; flex-direction: column; align-items: center; gap: 6px; transition: all 0.4s cubic-bezier(0.4,0,0.2,1); position: relative; }
.wl-orb-group.expanded { background: rgba(255,255,255,0.02); border-radius: 16px; padding: 16px; box-shadow: 0 0 30px rgba(0,0,0,0.3); z-index: 5; flex-basis: 100%; max-width: 600px; }
/* ── Orb ── */
.wl-orb { position: relative; border-radius: 50%; cursor: pointer; overflow: hidden; transition: all 0.35s cubic-bezier(0.4,0,0.2,1); }
.wl-orb.orb-sm { width: 70px; height: 70px; }
.wl-orb.orb-md { width: 90px; height: 90px; }
.wl-orb.orb-lg { width: 110px; height: 110px; }
.wl-orb:hover { transform: scale(1.1); }
.wl-orb-group.expanded .wl-orb { transform: scale(1.05); }
.wl-orb-glow { position: absolute; inset: -4px; border-radius: 50%; background: conic-gradient(from 0deg, hsla(var(--orb-hue),70%,55%,0.4), hsla(calc(var(--orb-hue)+60),70%,55%,0.2), hsla(var(--orb-hue),70%,55%,0.4)); animation: orbGlowSpin 8s linear infinite; z-index: 0; filter: blur(3px); }
@keyframes orbGlowSpin { to { transform: rotate(360deg); } }
.wl-orb-img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; position: relative; z-index: 1; }
.wl-orb-initials { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 800; color: hsla(var(--orb-hue),70%,75%,0.9); background: hsla(var(--orb-hue),40%,15%,0.9); border-radius: 50%; position: relative; z-index: 1; }
.wl-orb-ring { position: absolute; inset: -2px; border-radius: 50%; border: 2px solid hsla(var(--orb-hue),60%,50%,0.3); z-index: 2; pointer-events: none; }
.wl-orb-group.expanded .wl-orb-ring { border-color: hsla(var(--orb-hue),70%,60%,0.6); box-shadow: 0 0 16px hsla(var(--orb-hue),70%,50%,0.3); }
.wl-orb-label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); text-align: center; max-width: 120px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wl-orb-meta { font-size: 9px; color: rgba(255,255,255,0.3); text-align: center; }
/* ── Expanded ── */
.wl-orb-expanded { display: none; width: 100%; margin-top: 10px; }
.wl-orb-group.expanded .wl-orb-expanded { display: block; animation: expandFadeIn 0.3s ease; }
@keyframes expandFadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
/* ── Satellites (albums) ── */
.wl-orb-albums { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.wl-satellite { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; transition: all 0.15s; }
.wl-satellite:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.1); }
.wl-satellite-art { width: 44px; height: 44px; border-radius: 6px; overflow: hidden; flex-shrink: 0; background: rgba(255,255,255,0.05); }
.wl-satellite-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.wl-satellite-info { flex: 1; min-width: 0; }
.wl-satellite-name { font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wl-satellite-count { font-size: 10px; color: rgba(255,255,255,0.35); }
.wl-satellite-remove { width: 22px; height: 22px; border-radius: 50%; border: none; background: transparent; color: rgba(255,255,255,0.2); font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s; flex-shrink: 0; }
.wl-satellite:hover .wl-satellite-remove { opacity: 1; }
.wl-satellite-remove:hover { background: rgba(239,68,68,0.15); color: #f87171; }
/* ── Single pills ── */
.wl-orb-singles { display: flex; flex-wrap: wrap; gap: 6px; }
.wl-single-pill { display: flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 20px; font-size: 11px; color: rgba(255,255,255,0.6); transition: all 0.15s; }
.wl-single-pill:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.1); color: #fff; }
.wl-pill-art { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.wl-pill-name { max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wl-pill-remove { width: 16px; height: 16px; border-radius: 50%; border: none; background: transparent; color: rgba(255,255,255,0.2); font-size: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s; flex-shrink: 0; }
.wl-single-pill:hover .wl-pill-remove { opacity: 1; }
.wl-pill-remove:hover { background: rgba(239,68,68,0.15); color: #f87171; }
@media (max-width: 768px) {
.wl-nebula-bar { flex-direction: column; }
.wl-nebula-bar-actions { width: 100%; }
.wl-nebula-bar-actions button { flex: 1; }
.wl-orb.orb-lg { width: 80px; height: 80px; }
.wl-orb.orb-md { width: 70px; height: 70px; }
.wl-orb.orb-sm { width: 60px; height: 60px; }
.wl-nebula-field { gap: 14px; }
.wl-orb-group.expanded { max-width: 100%; }
}
/* ── Legacy (hidden) ── */
#wishlist-page-categories { display: none;
margin-bottom: 24px;
}