v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)

* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-03 08:49:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent df2d660d1e
commit 8c7cde5d5c
5 changed files with 395 additions and 19 deletions
+87 -3
View File
@@ -35,9 +35,52 @@
// ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// calls window.__fbMatchReviewChip() after each toolbar build; review
// actions here re-call it. The same fetch feeds the Settings status line.
// actions here re-call it. The same fetch feeds the Settings status line
// and, while a pass is running, a quiet toolbar progress line (below).
// Silent on failure — surfaces just stay as they are.
let _chipBusy = false;
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
// Quiet library-visible progress (launch polish): a plain text line next
// to the review chip while the background pass is working through the
// queue — "Matching your library — X of Y". No toast, no sound; it simply
// disappears when the pass finishes (hearing-safe, design §11).
function _setProgressLine(running, states, total) {
let el = document.getElementById('v3-songs-match-progress');
const unscanned = states.unscanned || 0;
if (!running || unscanned <= 0 || total <= 0) {
if (el) el.remove();
return;
}
if (!el) {
const chip = document.getElementById('v3-songs-match-review');
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
el = document.createElement('span');
el.id = 'v3-songs-match-progress';
el.className = 'text-xs text-fb-textDim';
chip.insertAdjacentElement('afterend', el);
}
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
}
// One-time transparency toast (launch polish): the first time this
// install is observed actually matching a real library, say plainly what
// is contacted, where results live, and where the switch is. Wrapped like
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
// never break the chip.
function _announceOnce(running, total) {
try {
if (!running || total <= 0) return;
if (localStorage.getItem('fb_enrich_announce_v1')) return;
localStorage.setItem('fb_enrich_announce_v1', '1');
window.fbNotify?.show({
title: 'Library matching is on',
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
icon: '📚',
});
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
}
async function refreshChip() {
if (_chipBusy) return;
_chipBusy = true;
@@ -62,7 +105,24 @@
if (st.unscanned) parts.push(st.unscanned + ' queued');
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
}
} catch (_) { /* offline — leave as-is */ } finally {
const running = !!body.running;
const total = body.total_songs || 0;
_setProgressLine(running, st, total);
_announceOnce(running, total);
// Poll only while a pass is actually running; a single guarded
// interval, cleared the moment the pass stops (no leaks).
if (running && !_pollTimer) {
_pollTimer = setInterval(refreshChip, 5000);
} else if (!running && _pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
} catch (_) {
// Offline — leave surfaces as they are, but stop any poll so a
// dead server isn't pinged every 5s forever (the next toolbar
// build / settings open restarts it if a pass is still running).
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
} finally {
_chipBusy = false;
}
}
@@ -455,10 +515,34 @@
});
}
// Stop the 5s poll when the library screen is left — the progress line and
// chip only live in the songs toolbar, so polling off-screen is pure waste
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
// this stays self-contained. Same single-guarded-interval invariant as
// refreshChip — no double-interval, cleared to null.
function wireScreenTeardown() {
const sm = window.feedBack;
if (!sm || typeof sm.on !== 'function') return;
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-songs') {
refreshChip(); // returning while a pass runs re-arms the poll
} else if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();
}, { once: true });
} else {
wireSettingsCard();
wireScreenTeardown();
}
window.__fbMatchReviewChip = refreshChip;
+88 -13
View File
@@ -591,16 +591,34 @@
const shelf = Array.isArray(suggestions) ? suggestions : [];
const { mastered, learning } = _repertoireCounts();
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
const meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
// Day-one zero-state (launch polish): no practice data and no real
// growth-edge rows → an invitational meter, never "0 of N". Starter
// rows are the server's no-attempts fallback, so they count as "no
// practice yet" too.
const starterShelf = shelf.length > 0 && !!shelf[0].starter;
const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
let meter;
if (invitational) {
meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">grows as you master songs</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:0%"></div></div>' +
'</div>';
} else {
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}
let shelfHtml = '';
if (shelf.length) {
@@ -613,9 +631,14 @@
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
'</button>').join('');
// Starter rows → the invitational "Start here" framing; real
// growth-edge rows → the usual "Keep practicing". Same cards.
const header = starterShelf
? '<h3 class="text-sm font-semibold text-fb-text">Start here</h3>' +
'<div class="text-xs text-fb-textDim mb-2">a few approachable songs to kick things off</div>'
: '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>';
shelfHtml =
'<section class="v3-kp-shelf mt-4">' +
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
'<section class="v3-kp-shelf mt-4">' + header +
'<div class="v3-kp-row">' + cards + '</div>' +
'</section>';
}
@@ -1816,6 +1839,20 @@
}
}
// Empty-library dead-end card (launch polish): only for a genuinely empty
// LOCAL library — a search / filter / format narrowing that merely matched
// nothing keeps the plain blank grid (saying "empty" there would lie), and
// remote providers own their own emptiness. The inline grid-column style
// spans the card across the grid without a new Tailwind class.
function _emptyLibraryHtml() {
if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return '';
return '<div class="flex flex-col items-center justify-center text-center py-8 gap-2" style="grid-column:1/-1">' +
'<div class="text-lg font-semibold text-fb-text">Your library is empty</div>' +
'<div class="text-sm text-fb-textDim max-w-md">Drop .sloppak files into your library folder, or use Upload above.</div>' +
'<button data-lib-empty-settings class="mt-3 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Open Settings</button>' +
'</div>';
}
let _winRAF = 0;
function requestWindowRender() {
if (_winRAF) return;
@@ -1837,8 +1874,16 @@
const rows = Math.ceil(total / Math.max(1, cols));
sizer.style.height = (rows * rowH) + 'px';
if (total === 0) {
grid.innerHTML = ''; grid.style.top = '0px';
grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
state.winRange = { start: 0, end: 0 };
if (grid.innerHTML) {
// The grid is absolutely positioned inside the sizer — give the
// sizer the card's height so it participates in layout.
sizer.style.height = grid.offsetHeight + 'px';
grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => {
if (window.showScreen) window.showScreen('settings');
});
}
return;
}
const sizerTop = _sizerTopInScroller(main, sizer);
@@ -2454,6 +2499,11 @@
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
let vocab = [];
try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
// Match provenance (launch polish): the drawer names what this chart
// matched, so a silently-wrong first match is visible where the
// metadata lives. 404 (no row yet) / offline → no line.
let enrich = null;
try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
if (_detailsEls) closeDetails(); // a concurrent open resolved first
const st = {
@@ -2462,6 +2512,7 @@
notes: meta.notes || '', tags: (meta.tags || []).slice(),
fav: !!song.favorite, artDataUrl: null,
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
enrich: enrich, // match provenance for the Identity section
};
const overlay = document.createElement('div');
@@ -2523,6 +2574,22 @@
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
}
// Match-provenance line under the Identity fields (launch polish): names
// the canonical identity this chart matched — the invisible-first-wrong-
// match fix — with the same Fix-match escape hatch the card menu offers.
// Only for settled matches; pending/review/failed rows stay silent here
// (the review chip / match facet own those states).
function provenanceHtml(st) {
const e = st.enrich;
if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return '';
const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — ');
if (!who) return '';
const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz';
return '<div class="flex items-baseline gap-2 text-xs text-fb-textDim">' +
'<span class="truncate">Matched: ' + esc(who) + ' (' + esc(src) + ')</span>' +
'<button data-det-fixmatch class="shrink-0 text-fb-primary hover:text-fb-primaryHi">Fix match</button></div>';
}
function detailsHtml(song, st, vocab) {
const art = st.artDataUrl || artUrl(song);
const diffBtns = [1, 2, 3, 4, 5].map((n) =>
@@ -2556,6 +2623,7 @@
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' +
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
provenanceHtml(st) +
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
// Personal practice layer — local, never shared
@@ -2623,6 +2691,13 @@
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
// Fix match → the exact flow the card ⋮ menu uses (match-review.js).
// The drawer closes first: the match modal sits below the drawer's
// z-index, and the fix supersedes the edit anyway.
$('[data-det-fixmatch]')?.addEventListener('click', () => {
closeDetails();
if (window.__fbFixMatch) window.__fbFixMatch(song);
});
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
// the pack file. The server recomputes proposals under its io lock, so