/*
* fee[dB]ack v0.3.0 — Songs / Library (#v3-songs), native rebuild.
*
* A vanilla-JS library browser over the existing /api/library* endpoints:
* provider selector (via the `library` capability, not DOM scraping), grid +
* tree views, sort, format filter, a tri-state filter drawer (arrangements /
* stems / lyrics / tunings), search (driven by the topbar), infinite scroll,
* fb song cards with accuracy badges (song_stats), favorite + save-for-later,
* and upload. Reuses window.playSong for playback (design/05: library is an
* active capability domain; everything else stays on the documented globals).
*/
(function () {
'use strict';
const sm = window.feedBack;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const enc = encodeURIComponent;
// Inverse of `enc` for matching a played song's filename back to a library
// card. The `stats:recorded` event (like `song:loading`) carries the
// filename exactly as it was handed to `playSong` — i.e. encodeURIComponent'd
// (see `playCard`, and the highway WS which decodeURIComponent's it). But
// cards key on the DECODED library filename (`cardKey` → `localFilename`),
// and `/api/stats/best` is server-canonicalized to that same decoded key, so
// an encoded filename matches no card and the post-play badge repaint silently
// no-ops. Decode to land in the card/`state.accuracy` key space. Idempotent
// for already-decoded names (no '%'); on malformed input falls back to the
// original so a real filename containing a literal '%' is never corrupted.
function decFn(fn) {
if (typeof fn !== 'string' || fn.indexOf('%') === -1) return fn || '';
try { return decodeURIComponent(fn); } catch (_) { return fn; }
}
const SORTS = [
['artist', 'Artist A–Z'], ['artist-desc', 'Artist Z–A'],
['title', 'Title A–Z'], ['title-desc', 'Title Z–A'],
['recent', 'Recently Added'], ['year-desc', 'Year (newest)'],
['year', 'Year (oldest)'], ['tuning', 'Tuning'],
// Mastery = best accuracy across arrangements (song_stats); unscored songs
// sort last either way. Ascending surfaces what needs work; never default.
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other'];
const PAGE_SIZE = 24;
// Extra rows rendered above/below the viewport so a fast scroll doesn't flash
// blank before the next window render lands.
const OVERSCAN_ROWS = 2;
const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
const btnCtrl = 'bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary';
const state = {
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
artist: '', album: '',
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] },
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
artistCatalog: [], renderedHash: '',
scrollBound: false,
songsById: {}, selectMode: false, selected: new Set(),
railLetters: null, railLettersAreSongCounts: false, railJumping: false,
// ── Windowed (virtualized) grid, stage 2 of #636 item 3 ──
// state.songs is a SPARSE array indexed by absolute library position
// (0..total-1); only the fetched pages are populated and only the visible
// window ± overscan is ever in the DOM. The sizer element gives the
// scrollbar the full-library geometry. See renderWindow / ensureWindow.
songs: [], // sparse: absoluteIndex → song row
pageCursors: {}, // pageIndex → next_cursor (keyset forward fast-path)
keysetOk: false, // did page 0 return a non-null cursor (local + keyset sort)?
pageProms: {}, // pageIndex → in-flight fetch promise (de-dupe + await)
epoch: 0, // bumped on every reset; a stale in-flight fetch checks it
geom: null, // { cols, rowH, gap } measured from the live grid
winRange: null, // { start, end } last rendered, to skip redundant renders
renderedSelectMode: null, // the selectMode the current window was rendered under
gridResizeBound: false,
};
// ── A–Z jump rail ───────────────────────────────────────────────────────
// Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z.
const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
// The rail only makes sense for the alphabetical sorts; for recent/year/
// tuning a letter jump is meaningless, so it's hidden. Returns the column
// the active sort keys on ('artist' | 'title') or null when not alphabetical.
function railSortColumn() {
if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist';
if (state.sort === 'title' || state.sort === 'title-desc') return 'title';
return null;
}
// The bucket a song falls in for the active sort: first char of the sort
// column, uppercased; anything non-A–Z (digits, symbols, accents, blank)
// buckets under '#'. Mirrors the server's letter grouping in query_stats —
// which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY
// is likewise raw, so we must NOT trim here either: a leading-space title
// sorts (and buckets) under '#' on both sides, keeping the rail consistent.
function songBucket(song) {
const col = railSortColumn();
if (!col) return '';
const raw = String((col === 'title' ? song.title : song.artist) || '');
const ch = raw.charAt(0).toUpperCase();
return (ch >= 'A' && ch <= 'Z') ? ch : '#';
}
function activeFilterCount() {
const f = state.filters;
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
(f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) +
(state.artist ? 1 : 0) + (state.album ? 1 : 0);
}
function _getV3MainScroller() { return document.getElementById('v3-main'); }
function buildLibraryStateHash(st) {
const f = (st && st.filters) || {};
return JSON.stringify({
view: st.view || 'grid',
q: st.q || '',
sort: st.sort || 'artist',
provider: st.provider || 'local',
format: st.format || '',
artist: st.artist || '',
album: st.album || '',
filters: {
arr_has: [...(f.arr_has || [])].sort(),
arr_lacks: [...(f.arr_lacks || [])].sort(),
stem_has: [...(f.stem_has || [])].sort(),
stem_lacks: [...(f.stem_lacks || [])].sort(),
lyrics: f.lyrics || '',
tunings: [...(f.tunings || [])].sort(),
mastery: [...(f.mastery || [])].sort(),
},
});
}
function _libraryStateHash() { return buildLibraryStateHash(state); }
function _saveLibraryScrollSnapshot() {
const main = _getV3MainScroller();
// Geometry is now stable (the sizer reserves the full scroll height
// regardless of how many cards are actually in the DOM), so the scroll
// position alone is enough to restore — no page-depth bookkeeping.
const snap = {
hash: _libraryStateHash(),
scrollTop: main ? main.scrollTop : 0,
view: state.view,
};
try { sessionStorage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap)); } catch (e) { /* quota / private mode */ }
}
function _readLibraryScrollSnapshot() {
try {
const raw = sessionStorage.getItem(SCROLL_STATE_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
return (snap && typeof snap === 'object') ? snap : null;
} catch (e) { return null; }
}
function _clearLibraryScrollSnapshot() {
try { sessionStorage.removeItem(SCROLL_STATE_KEY); } catch (e) { /* */ }
}
function _applyMainScrollTop(scrollTop) {
const main = _getV3MainScroller();
if (!main) return;
const top = Math.max(0, Number(scrollTop) || 0);
const apply = () => { main.scrollTop = top; };
apply();
requestAnimationFrame(apply);
setTimeout(apply, 0);
}
// The windowed grid keeps only a slice of cards in the DOM, so "intact" can no
// longer mean "has cards" — it means the grid + sizer chrome exist and page 0
// is loaded (state.total known, first rows present), so renderWindow() can
// repaint the right slice at any scroll position.
function _gridDomIntact() {
const grid = document.getElementById('v3-songs-grid');
const sizer = document.getElementById('v3-songs-gridsizer');
return !!grid && !!sizer && state.total > 0 && state.songs[0] !== undefined;
}
function _treeDomIntact() {
const tree = document.getElementById('v3-songs-tree');
if (!tree) return false;
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
}
function queryParams(extra, opts) {
const f = state.filters;
const skipArtistAlbum = opts && opts.catalog;
const p = new URLSearchParams();
p.set('provider', state.provider);
p.set('sort', state.sort);
if (state.format) p.set('format', state.format);
if (state.q) p.set('q', state.q);
if (!skipArtistAlbum && state.artist) p.set('artist', state.artist);
if (!skipArtistAlbum && state.album) p.set('album', state.album);
if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
if (f.lyrics) p.set('has_lyrics', f.lyrics);
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
Object.entries(extra || {}).forEach(([k, v]) => p.set(k, v));
return p;
}
// The active filter set as a smart-collection rule object (raw query-param
// format the backend stores). Mirrors queryParams' filter fields, minus
// provider/page/size. Empty object → nothing worth saving as a collection.
function currentFilterRules() {
const f = state.filters, r = {};
if (state.q) r.q = state.q;
if (state.format) r.format = state.format;
if (state.artist) r.artist = state.artist;
if (state.album) r.album = state.album;
if (f.arr_has.length) r.arrangements_has = f.arr_has.join(',');
if (f.arr_lacks.length) r.arrangements_lacks = f.arr_lacks.join(',');
if (f.stem_has.length) r.stems_has = f.stem_has.join(',');
if (f.stem_lacks.length) r.stems_lacks = f.stem_lacks.join(',');
if (f.lyrics) r.has_lyrics = f.lyrics;
if (f.tunings.length) r.tunings = f.tunings.join(',');
if (state.sort && state.sort !== 'artist') r.sort = state.sort;
return r;
}
// Save the current filter set as a smart collection (a saved live query that
// shows up as a source in the picker). #636 item 2.
async function saveCurrentAsCollection() {
const rules = currentFilterRules();
if (!Object.keys(rules).length) return;
const name = ((await window.uiPrompt({
title: 'Save as collection',
label: 'A live view of the current filters, in the source picker.',
okLabel: 'Save',
placeholder: 'Collection name',
})) || '').trim();
if (!name) return;
try {
const res = await fetch('/api/collections', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, rules }),
});
if (!res.ok) return;
const col = (await res.json()).collection;
closeDrawer();
if (col && col.id != null) state.provider = 'collection:' + col.id;
await render(); // rebuilds the toolbar (provider picker now lists + selects it)
} catch (e) { /* offline / aborted — leave the drawer as-is */ }
}
function albumsForArtist(name) {
const a = (state.artistCatalog || []).find((x) => x.name === name);
return a ? (a.albums || []) : [];
}
function _chromeIntact() {
return !!(document.getElementById('v3-songs-filters') &&
document.getElementById('v3-songs-artist') &&
document.getElementById('v3-songs-grid'));
}
function artistSelectHtml() {
const opts = ['']
.concat((state.artistCatalog || []).map((a) =>
''));
return opts.join('');
}
function albumSelectHtml() {
if (!state.artist) {
return '';
}
const albums = albumsForArtist(state.artist);
const opts = ['']
.concat(albums.map((n) =>
''));
return opts.join('');
}
function refreshArtistAlbumSelects() {
const artistEl = document.getElementById('v3-songs-artist');
const albumEl = document.getElementById('v3-songs-album');
if (artistEl) artistEl.innerHTML = artistSelectHtml();
if (albumEl) {
albumEl.innerHTML = albumSelectHtml();
albumEl.disabled = !state.artist;
}
}
function syncChromeFromState() {
const map = {
'v3-songs-provider': state.provider,
'v3-songs-sort': state.sort,
'v3-songs-format': state.format,
};
Object.entries(map).forEach(([id, val]) => {
const el = document.getElementById(id);
if (el && el.value !== val) el.value = val;
});
refreshArtistAlbumSelects();
const gridBtn = document.getElementById('v3-songs-grid-btn');
const treeBtn = document.getElementById('v3-songs-tree-btn');
if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
const folderBtn = document.getElementById('v3-songs-folder-btn');
if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
// Select button tracks state.selectMode — the screen-leave teardown clears
// select mode, so a cached-DOM re-entry must re-style the button (and the
// window re-renders without checkboxes via renderWindow's selectMode check).
const selBtn = document.getElementById('v3-songs-select');
if (selBtn) selBtn.className = btnCtrl + (state.selectMode ? ' bg-fb-primary text-white' : '');
updateFilterBadge();
}
async function loadArtistCatalog() {
const artists = [];
let page = 0, total = Infinity;
while (artists.length < total) {
const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }, { catalog: true }).toString());
if (!data || !Array.isArray(data.artists)) break;
artists.push(...data.artists);
total = (data.total_artists != null) ? data.total_artists : artists.length;
if (!data.artists.length || page > 1000) break;
page++;
}
state.artistCatalog = artists.map((a) => ({
name: a.name,
albums: (a.albums || []).map((al) => al.name),
}));
if (state.artist && !state.artistCatalog.some((a) => a.name === state.artist)) {
state.artist = '';
state.album = '';
} else if (state.album && !albumsForArtist(state.artist).includes(state.album)) {
state.album = '';
}
return state.artistCatalog;
}
function resetScrollToTop() {
_clearLibraryScrollSnapshot();
_applyMainScrollTop(0);
}
function setArtist(value) {
state.artist = value || '';
if (state.album && !albumsForArtist(state.artist).includes(state.album)) state.album = '';
resetScrollToTop();
refreshArtistAlbumSelects();
reload();
}
function setAlbum(value) {
if (!state.artist) { state.album = ''; return; }
state.album = value || '';
resetScrollToTop();
reload();
}
async function jget(url) { try { const r = await fetch(url); return r.ok ? r.json() : null; } catch (e) { return null; } }
// ── Provider-aware song helpers ────────────────────────────────────────
// Remote library providers (feedBack-plugin-remote-library-*) expose songs
// by provider-owned id with their own art/sync/play flow. Reuse the legacy
// app.js globals (the shared engine) so v3 behaves identically for remote
// providers instead of assuming every row is a local file. All degrade to
// the local path when the helpers/providers aren't present.
function songId(s) {
return (window._librarySongId ? window._librarySongId(s) : (s.filename || '')) || '';
}
function localFilename(s) {
return window._libraryLocalFilename ? window._libraryLocalFilename(s, state.provider) : (s.filename || '');
}
// Stable per-card key: the local filename when present (local song, or a
// synced remote one), else the provider song id.
function cardKey(s) { return localFilename(s) || songId(s); }
function artUrl(song) {
if (window._librarySongArtUrl) return window._librarySongArtUrl(song, state.provider);
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
return song.filename ? '/api/song/' + enc(song.filename) + '/art' + v : '';
}
// Play a card: local (or already-synced remote) → playSong the local file;
// an unsynced remote song → sync it first, then play when ready.
function playCard(song, arrIdx) {
if (!song) return;
_saveLibraryScrollSnapshot();
const lf = localFilename(song);
if (lf) { if (window.playSong) window.playSong(enc(lf), arrIdx); return; }
const sid = songId(song);
if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true });
}
// Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art,
// default) or 'tree' (inline percentage in the list row). Both carry the
// .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and
// replace them in place without re-rendering the whole list.
function accuracyBadge(filename, variant) {
const acc = state.accuracy[filename];
if (acc == null) return '';
const pct = Math.round(acc * 100);
if (variant === 'tree') {
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
return '' + pct + '%';
}
const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white';
return '' +
'' + pct + '%';
}
// After a song is scored, the badge for that card is stale until the next
// full render(). Refresh state.accuracy from the server and patch the badge
// of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
// tracks filenames scored while the library was off-screen, applied on enter.
const _dirtyScores = new Set();
// Set when a library scan / DLC-folder change happened while this screen was
// off (or showing a stale, e.g. pre-DLC empty, grid). The grid's cached DOM /
// snapshot would otherwise survive a sidebar return, so we force a full
// re-fetch on the next entry. (feedBack — "No DLC until restart".)
let _libraryDirty = false;
function repaintAccuracy(key) {
const apply = (el, variant) => {
if (el.getAttribute('data-fn') !== key) return;
const old = el.querySelector('.fb-acc-badge');
const html = accuracyBadge(key, variant);
if (variant === 'grid') {
const art = el.querySelector('[data-v3-play]');
if (!art) return;
if (old) old.remove();
if (html) art.insertAdjacentHTML('beforeend', html);
} else if (old) {
if (html) old.outerHTML = html; else old.remove();
} else if (html) {
// No prior badge in this row — insert before the favorite button
// so it keeps its slot (after the format chip).
const fav = el.querySelector('[data-fav]');
if (fav) fav.insertAdjacentHTML('beforebegin', html);
else el.insertAdjacentHTML('beforeend', html);
}
};
document.querySelectorAll('#v3-songs-grid [data-fn]').forEach((el) => apply(el, 'grid'));
document.querySelectorAll('#v3-songs-tree [data-fn]').forEach((el) => apply(el, 'tree'));
}
async function applyScoreRefresh() {
if (!_dirtyScores.size) return;
const fresh = await jget('/api/stats/best');
// Fetch failed — keep the entries dirty so the next trigger (or screen
// enter) retries rather than silently dropping the badge update.
if (!fresh) return;
state.accuracy = fresh;
const keys = Array.from(_dirtyScores);
_dirtyScores.clear();
keys.forEach(repaintAccuracy);
// A new score shifts the repertoire meter + the keep-practicing shelf.
renderLibraryHome();
}
// ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
// Both read data we already have: state.accuracy (/api/stats/best =
// {filename: best_accuracy}) and /api/stats/recent. A song is "in your
// repertoire" at the same threshold the green accuracy badge uses (>= 0.9);
// a started song below that is "in progress". This is descriptive
// encouragement — it never gates content, decays, or nags (the goal-gradient
// / endowed-progress idea, kept healthy).
const MASTERY_ACCURACY = 0.9;
function _repertoireCounts() {
let mastered = 0, learning = 0;
for (const v of Object.values(state.accuracy || {})) {
if (typeof v !== 'number') continue;
if (v >= MASTERY_ACCURACY) mastered++; else learning++;
}
return { mastered, learning };
}
// The home block is the unfiltered "front door": shown on the grid view when
// the user isn't running a focused query (search / filter) or selecting.
// Local provider only — the meter's mastered count and the shelf both read
// local practice stats (state.accuracy / /api/stats/recent), so on a remote
// provider they'd mix local numerators with a remote song total and play
// local files while browsing a remote library. Hide it there.
function libHomeVisible() {
return state.view === 'grid' && state.provider === 'local'
&& !state.selectMode && !state.q && activeFilterCount() === 0;
}
let _homeToken = 0;
async function renderLibraryHome() {
const host = document.getElementById('v3-lib-home');
if (!host) return;
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
// A newer render (view/filter/score change) supersedes this one so a
// slow response can't repaint a home the grid already moved past.
const myToken = ++_homeToken;
// Unfiltered library size for the meter denominator (the grid's
// state.total tracks the active filter; the meter is library-wide) +
// recently-played rows for the shelf, fetched together.
const [stats, recent] = await Promise.all([
jget('/api/library/stats?provider=' + enc(state.provider)),
jget('/api/stats/recent?limit=24'),
]);
if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
if (_homeToken === myToken) host.classList.add('hidden');
return;
}
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
if (total <= 0) { host.classList.add('hidden'); return; } // empty library
// Shelf = recently-played, not-yet-mastered songs, newest first. Mastery
// is per-SONG (state.accuracy = MAX best across arrangements, what the
// green badge shows) — recents are per-(song,arrangement), so dedupe by
// filename and gate on the song's best, keeping the shelf and its badges
// consistent (no green-badged "keep practicing" card, no dupes).
const acc = state.accuracy || {};
const seen = new Set();
const shelf = (Array.isArray(recent) ? recent : [])
.filter((r) => {
if (!r || seen.has(r.filename)) return false;
const best = acc[r.filename];
if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false;
seen.add(r.filename);
return true;
})
.slice(0, 8);
const { mastered, learning } = _repertoireCounts();
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
const meter =
'
';
let shelfHtml = '';
if (shelf.length) {
const cards = shelf.map((r) =>
'').join('');
shelfHtml =
'' +
'
Keep practicing
' +
'
' + cards + '
' +
'';
}
host.innerHTML = meter + shelfHtml;
host.classList.remove('hidden');
// The home block sits above the grid sizer, so its height shifts where the
// window maps in scroll space — repaint the window once it's laid out.
if (state.view === 'grid') requestWindowRender();
// Wire shelf cards → play (mirrors playCard's local path; recents are
// always local-library rows, so no provider sync is needed).
host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => {
const fn = btn.getAttribute('data-kp');
const arr = btn.getAttribute('data-arr');
if (!fn || !window.playSong) return;
_saveLibraryScrollSnapshot();
window.playSong(enc(fn), arr === '' ? undefined : Number(arr));
}));
}
// Toggle/refresh the home block on view/sort/filter/search changes.
function updateLibraryHome() {
const host = document.getElementById('v3-lib-home');
if (!host) return;
if (!libHomeVisible()) { host.classList.add('hidden'); return; }
renderLibraryHome();
}
// Source format of a song — prefer the server's `format` field, fall back
// to the filename extension. Returns '' for unknown.
function fmtLabel(song) {
let f = (song.format || '').toLowerCase();
if (!f) {
const fn = (song.filename || '').toLowerCase();
f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : '';
}
return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-based cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtLabel(song);
if (!l) return '';
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '' + l + '';
}
// Clickable arrangement chips — one