From 693135896e1d5971b4d53c5d6c01cebe2745cc5d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 23:25:30 -0400 Subject: [PATCH 1/3] fix(web): SearchInput keeps focus across debounced URL updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous one-way `{value}` + manual `oninput` rewrite let Svelte re-set the input's DOM value whenever navigation completed, which moved the cursor (and on some browsers, blurred the input) mid-typing. Switch to Svelte 5's `bind:value` — it skips DOM writes when the JS value already matches the DOM, so the user's typing isn't disturbed. The URL-resync `\$effect` is unchanged: it still updates `value` for back/forward navigation but is a no-op during normal typing because the debounce already wrote the matching URL. Co-Authored-By: Claude Opus 4.7 --- web/src/lib/components/SearchInput.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/SearchInput.svelte b/web/src/lib/components/SearchInput.svelte index f1755709..2708d12d 100644 --- a/web/src/lib/components/SearchInput.svelte +++ b/web/src/lib/components/SearchInput.svelte @@ -24,8 +24,7 @@ goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage }); } - function onInput(e: Event) { - value = (e.currentTarget as HTMLInputElement).value; + function onInput() { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; @@ -50,7 +49,7 @@ type="search" placeholder="Search artists, albums, tracks…" aria-label="Search" - {value} + bind:value oninput={onInput} onkeydown={onKey} class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" From b7a59a94735f6baa5dd870fb2d82fdd943ffa8b9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 23:37:21 -0400 Subject: [PATCH 2/3] fix(web): SearchInput passes keepFocus to goto() to prevent blur on debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual cause of the search-field focus loss: SvelteKit's `goto()` defaults to resetting focus on navigation, so each debounced URL update blurred the input mid-typing. Pass `keepFocus: true` to both `goto()` calls in `navigate()`. The earlier `bind:value` change in 6931358 didn't address this — it was solving a hypothetical DOM-write-side cursor-jump issue rather than the real focus-reset behavior. Leaving `bind:value` in place since it's still cleaner Svelte 5. Tests updated to match the new goto args. --- web/src/lib/components/SearchInput.svelte | 10 ++++++++-- web/src/lib/components/SearchInput.test.ts | 10 +++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/SearchInput.svelte b/web/src/lib/components/SearchInput.svelte index 2708d12d..174fd5e2 100644 --- a/web/src/lib/components/SearchInput.svelte +++ b/web/src/lib/components/SearchInput.svelte @@ -17,11 +17,17 @@ function navigate(next: string) { const onSearchPage = page.url.pathname.startsWith('/search'); const trimmed = next.trim(); + // keepFocus: SvelteKit blurs the active element on navigation by default. + // For a typeahead-style URL update we want the user to keep typing + // without re-clicking the input every 250 ms. if (trimmed === '') { - if (onSearchPage) goto('/search', { replaceState: true }); + if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true }); return; } - goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage }); + goto(`/search?q=${encodeURIComponent(trimmed)}`, { + replaceState: onSearchPage, + keepFocus: true, + }); } function onInput() { diff --git a/web/src/lib/components/SearchInput.test.ts b/web/src/lib/components/SearchInput.test.ts index 84517669..ca1f97bb 100644 --- a/web/src/lib/components/SearchInput.test.ts +++ b/web/src/lib/components/SearchInput.test.ts @@ -41,7 +41,7 @@ describe('SearchInput', () => { expect(goto).not.toHaveBeenCalled(); vi.advanceTimersByTime(250); expect(goto).toHaveBeenCalledTimes(1); - expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false }); + expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false, keepFocus: true }); }); test('rapid keystrokes debounce to a single goto', async () => { @@ -54,7 +54,7 @@ describe('SearchInput', () => { await fireEvent.input(input, { target: { value: 'hel' } }); vi.advanceTimersByTime(250); expect(goto).toHaveBeenCalledTimes(1); - expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false }); + expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false, keepFocus: true }); }); test('typing while on /search uses replaceState=true', async () => { @@ -63,7 +63,7 @@ describe('SearchInput', () => { const input = screen.getByRole('searchbox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'he' } }); vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true }); + expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true, keepFocus: true }); }); test('clearing input on /search calls goto("/search") (drops ?q=)', async () => { @@ -72,7 +72,7 @@ describe('SearchInput', () => { const input = screen.getByRole('searchbox') as HTMLInputElement; await fireEvent.input(input, { target: { value: '' } }); vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search', { replaceState: true }); + expect(goto).toHaveBeenCalledWith('/search', { replaceState: true, keepFocus: true }); }); test('Escape clears the input value', async () => { @@ -89,6 +89,6 @@ describe('SearchInput', () => { const input = screen.getByRole('searchbox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'a b&c' } }); vi.advanceTimersByTime(250); - expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false }); + expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false, keepFocus: true }); }); }); From 95d68e3d3d136cd4fa3dfaa9fc524d4e60323622 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 23:50:00 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20M3.5=20polish=20=E2=80=94=20genre?= =?UTF-8?q?=20splitting=20+=20radio=20button=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two carry-overs from M3 verification, bundled with the search-input fix already on dev (b7a59a9). 1. Genre splitting in BuildSessionVector The library has many tracks whose genre tag is a denormalized multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them as one opaque tag means a single-genre "Pop" track and a multi-genre track listing Pop both fail to share the Pop key, so the tags axis in similarity scoring (weight 0.7!) returned 0 in nearly all cases on real libraries. Result: contextual scoring couldn't differentiate. Add splitGenres() that splits on ; and , and trims whitespace; strings without a delimiter come back as a single-element slice (so the existing single-genre cases keep working unchanged). Concatenated-without-separator output ("ElectronicComplextroGlitch Hop") stays opaque — that needs a genre dictionary, out of scope. 2. Play-radio button on TrackRow playRadio() was only wired to the click handler on /search and /search/tracks, leaving /library/liked, album pages, and search results' inner clicks with no way to start a radio. Adds a small radio button to TrackRow (between LikeButton and the +queue button). Click → playRadio(track.id), with stopPropagation so the row's own activate handler doesn't also fire. Tests: - 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon split, no-separator stays opaque. Existing single-genre tests pass unchanged. - 1 new TrackRow test: radio button calls playRadio with track id and does NOT trigger row play. Verified locally: go -short -race ./... clean, golangci-lint clean, svelte-check 0/0, 175 vitest tests (was 174), web build succeeds. Co-Authored-By: Claude Opus 4.7 --- internal/recommendation/sessionvector.go | 25 ++++++- internal/recommendation/sessionvector_test.go | 71 +++++++++++++++++++ web/src/lib/components/TrackRow.svelte | 16 ++++- web/src/lib/components/TrackRow.test.ts | 12 +++- 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/internal/recommendation/sessionvector.go b/internal/recommendation/sessionvector.go index 94cd3154..d244d51e 100644 --- a/internal/recommendation/sessionvector.go +++ b/internal/recommendation/sessionvector.go @@ -1,6 +1,8 @@ package recommendation import ( + "strings" + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" @@ -28,7 +30,9 @@ func BuildSessionVector(priorTracks []dbq.Track) SessionVector { v.Artists = append(v.Artists, artistID) } if t.Genre != nil && *t.Genre != "" { - v.Tags[*t.Genre]++ + for _, g := range splitGenres(*t.Genre) { + v.Tags[g]++ + } } v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID)) } @@ -41,3 +45,22 @@ func uuidString(u pgtype.UUID) string { } return u.String() } + +// splitGenres splits a track's denormalized genre string on the common +// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims +// whitespace; drops empty fragments. Strings with no delimiter come back +// as a single-element slice. Concatenated-without-separator inputs (e.g. +// "ElectronicComplextroGlitch Hop" from broken tag-editor output) cannot +// be split without a genre dictionary and stay as one opaque tag. +func splitGenres(s string) []string { + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == ';' || r == ',' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/recommendation/sessionvector_test.go b/internal/recommendation/sessionvector_test.go index 745b3bf9..91ba38f4 100644 --- a/internal/recommendation/sessionvector_test.go +++ b/internal/recommendation/sessionvector_test.go @@ -123,6 +123,77 @@ func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) { } } +func TestBuildSessionVector_MultiGenreSplitsOnSemicolon(t *testing.T) { + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{ + track(a1, "Indie Pop; Pop; Alternative Pop"), + track(a1, "Pop"), + track(a1, "Boom Bap; Hip Hop; Lo-Fi"), + }) + // Pop appears once on track 1 and once on track 2 → count 2. + if v.Tags["Pop"] != 2 { + t.Errorf("Tags['Pop'] = %d, want 2 (one per matching track)", v.Tags["Pop"]) + } + // Indie Pop, Alternative Pop only on track 1. + if v.Tags["Indie Pop"] != 1 || v.Tags["Alternative Pop"] != 1 { + t.Errorf("Indie/Alt Pop counts wrong: %+v", v.Tags) + } + // Track 3's genres. + if v.Tags["Boom Bap"] != 1 || v.Tags["Hip Hop"] != 1 || v.Tags["Lo-Fi"] != 1 { + t.Errorf("track-3 genre counts wrong: %+v", v.Tags) + } +} + +func TestBuildSessionVector_NoSeparatorStaysOpaque(t *testing.T) { + // Concatenated strings without separators (broken tag-editor output) + // can't be parsed; they stay as one tag and only match identical strings. + a1 := uuid("11111111-1111-1111-1111-111111111111") + v := BuildSessionVector([]dbq.Track{ + track(a1, "ElectronicComplextroGlitch Hop"), + track(a1, "ElectronicComplextroGlitch Hop"), + }) + if v.Tags["ElectronicComplextroGlitch Hop"] != 2 { + t.Errorf("opaque tag count = %d, want 2", v.Tags["ElectronicComplextroGlitch Hop"]) + } + if len(v.Tags) != 1 { + t.Errorf("len(Tags) = %d, want 1", len(v.Tags)) + } +} + +func TestSplitGenres(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"Pop", []string{"Pop"}}, + {"Pop; Rock", []string{"Pop", "Rock"}}, + {"Pop, Rock", []string{"Pop", "Rock"}}, + {"Pop; Rock, Jazz", []string{"Pop", "Rock", "Jazz"}}, + {" Pop ; Rock ", []string{"Pop", "Rock"}}, + {";; Pop ;;", []string{"Pop"}}, + {"ElectronicComplextroGlitch Hop", []string{"ElectronicComplextroGlitch Hop"}}, + } + for _, c := range cases { + got := splitGenres(c.in) + if c.want == nil { + if len(got) != 0 { + t.Errorf("splitGenres(%q) = %v, want empty", c.in, got) + } + continue + } + if len(got) != len(c.want) { + t.Errorf("splitGenres(%q) = %v, want %v", c.in, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitGenres(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + func TestBuildSessionVector_JSONRoundTrip(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index 68a76432..eeafee1e 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -1,7 +1,7 @@
{track.track_number ?? '—'} {track.title} +