fix(playlists): stay within the shipped Tailwind class set

Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.

Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.

Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.

The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-19 00:05:14 -05:00
parent ecc7e61251
commit dbee1b2489
3 changed files with 18 additions and 8 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -153,9 +153,13 @@
if (!total) return ''; if (!total) return '';
const mism = results.filter((r) => r.state === 'mismatch').length; const mism = results.filter((r) => r.state === 'mismatch').length;
const unk = results.filter((r) => r.state === 'unknown').length; const unk = results.filter((r) => r.state === 'unknown').length;
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-x-3 gap-y-2 '; // Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
// in the committed tailwind.min.css, and regenerating it is not
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
// bytes), so the summary bar stays within the shipped class set.
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
if (!mism) { if (!mism) {
return '<div class="' + box + 'border-fb-good/40 bg-fb-good/10 text-fb-good">' + return '<div class="' + box + 'border-fb-good/40 bg-fb-good/30 text-fb-good">' +
'<span>✓ All ' + total + ' songs are in your tuning.</span>' + '<span>✓ All ' + total + ' songs are in your tuning.</span>' +
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') + (unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') +
'</div>'; '</div>';
@@ -165,7 +169,7 @@
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') + (unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') +
'<span class="flex-1"></span>' + '<span class="flex-1"></span>' +
'<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' + '<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' +
'<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-fb-accent/50 text-fb-accent hover:bg-fb-accent/10">Remove them…</button>' + '<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-amber-400/40 text-fb-text hover:bg-fb-card">Remove them…</button>' +
'</div>'; '</div>';
} }
@@ -201,10 +205,13 @@
// user data, so the confirm has to be a list, not a count. // user data, so the confirm has to be a list, not a count.
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song); const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
if (!doomed.length) return; if (!doomed.length) return;
const names = doomed.map((s) => '<li>' + esc(s.title || s.filename) + '</li>').join(''); const names = doomed.map((s) => '<div>• ' + esc(s.title || s.filename) + '</div>').join('');
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's') const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
+ ' from "' + esc(pl.name) + '"?' + ' from "' + esc(pl.name) + '"?'
+ '<ul class="mt-2 text-xs list-disc list-inside max-h-48 overflow-auto">' + names + '</ul>' // Bulleted with a literal •, and sized with max-h-32, so the
// confirm needs no Tailwind class the committed CSS lacks —
// regenerating tailwind.min.css is not reproducible off CI.
+ '<div class="mt-2 text-xs max-h-32 overflow-y-auto">' + names + '</div>'
+ '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>'; + '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>';
const ok = (typeof window.uiConfirm === 'function') const ok = (typeof window.uiConfirm === 'function')
? await window.uiConfirm({ ? await window.uiConfirm({
+5 -2
View File
@@ -261,8 +261,11 @@ test('the check itself never mutates the playlist', () => {
test('bulk removal names every song and is confirmed before any DELETE', () => { test('bulk removal names every song and is confirmed before any DELETE', () => {
const body = extractBlock(PL_SRC, 'async function applyTuningCheck('); const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
// The confirm is built from the doomed titles // The confirm is built from the doomed titles, each escaped. The row markup
assert.match(body, /doomed\.map\(\(s\) => '<li>' \+ esc\(s\.title \|\| s\.filename\)/); // moved from <li> to a bulleted <div> so the confirm needs no Tailwind class
// the committed CSS lacks — what matters is that every song is named and
// escaped, not which element wraps it.
assert.match(body, /doomed\.map\(\(s\) => '<(?:li|div)>[^']*' \+ esc\(s\.title \|\| s\.filename\)/);
// … it is awaited, and an early return happens before the delete loop. // … it is awaited, and an early return happens before the delete loop.
const confirmAt = body.indexOf('uiConfirm'); const confirmAt = body.indexOf('uiConfirm');
const bailAt = body.indexOf('if (!ok) return;'); const bailAt = body.indexOf('if (!ok) return;');