Commit Graph
685 Commits
Author SHA1 Message Date
yonlu 8f6a4c6a8e feat: 'In Library' badges, artist images on local pages, MBID-based Tier 3
Three features wired together:

1. 'In Library' badges on explore search results:
   CheckLibraryMBIDs Wails binding batch-checks which search result
   MBIDs exist in the local library. Green badges render on matching
   artist cards and album cards.

2. Artist images on local artist-details page:
   Local artist pages now call GetArtistMBID(name) to resolve the
   MBID from tags, then GetArtistImageURL(mbid) to fetch the cached
   Wikimedia photo. Falls back to initial-letter avatar.

3. Tier 3 search index uses direct MBIDs from tags:
   buildTier3Library now reads artists.mbid column (from audio tags)
   for direct MBID matching, falling back to name matching for
   untagged artists. Eliminates false matches and catches artists
   that name matching misses.
2026-03-26 09:34:57 -04:00
yonlu b941057a46 feat: extract MusicBrainz IDs from audio tags and store in library DB
Migration 13 adds nullable mbid TEXT columns to artists,
release_groups, and recordings with partial indexes.

Metadata extraction (tags.go) now reads MusicBrainz IDs from Raw()
tags — handles both Vorbis (musicbrainz_artistid) and ID3v2
(MusicBrainz Artist Id) key formats.

Scan pipeline (library.go) updates MBIDs after entity upsert via
raw SQL UPDATE. Only sets mbid if currently NULL (preserves existing
values on rescan).

LibraryMBIDIndex (librarymbid.go) provides:
- CheckMBIDs: batch lookup for 'In Library' badges
- GetArtistMBID: single artist name→MBID lookup
- AllArtistMBIDs: full dump for search index Tier 3

MBIDs will be populated on next library rescan. Existing files
need a rescan to backfill.
2026-03-26 09:24:39 -04:00
yonlu 806de8fd45 feat: migration 13 — add MBID columns to artists, release_groups, recordings
Add nullable TEXT mbid column to artists, release_groups, and
recordings tables. Partial indexes on each (WHERE mbid IS NOT NULL)
for fast MBID lookups without bloating the index for rows without
MBIDs.

Enables linking local library entities to MusicBrainz/ListenBrainz
explore data, artist image sharing, and 'In Library' badges.
2026-03-26 09:17:55 -04:00
yonlu c9b3c86f37 perf: unified per-artist indexing — discography + image in parallel
Restructure indexOneArtist to run LB discography fetches and MB
artist image resolution concurrently. They use different rate
limiters (LB: 3 req/s, MB: 1 req/s) so they overlap without
contention.

Per artist, the indexer now runs two parallel pipelines:
  LB pipeline: top-release-groups + top-recordings
  MB pipeline: url-rels → Wikidata P18 → Wikimedia image fetch

All artist images are pre-cached during the index build instead
of being resolved on-demand during search. Total build time
drops from ~105 min (sequential) to ~63 min (parallel, MB-bound).

SearchIndex now takes ArtistImageProvider as a dependency. The
Service constructor creates artistImg before the index so both
can share it.
2026-03-26 08:54:43 -04:00
yonlu 963269b753 feat: artist image disk cache + fix top results artist photos
Two changes:

1. Artist image disk cache: ArtistImageProvider now fetches the
   actual image bytes from Wikimedia Commons and caches them on
   disk (~/.local/share/yellowjacket/artist-image-cache/{mbid}.jpg).
   Returns base64 data URLs, same pattern as CoverArtProxy.
   First lookup: resolve URL via MB/Wikidata + fetch image (~2s).
   Subsequent: instant from disk cache.
   404s cached as empty files to avoid re-fetching.

2. Top results artist photos: the Top Results section now shows
   artist images from the artistImageCache, same as the Artists
   section. Also shows englishName in the top card display name.
2026-03-25 22:28:52 -04:00
yonlu 9b88b88523 fix: rate-limit MB url-rels fetches, serialize frontend image loads
Artist image resolution was hitting musicbrainz.org with 10
concurrent unthrottled requests per search — enough to trigger
MB's rate limit rejection. Two fixes:

Backend: add dedicated 1 req/s RateLimiter for MB url-rels fetches
in ArtistImageProvider. Each fetch waits on the limiter before
the HTTP call. Results are cached 30 days so repeat lookups are
instant.

Frontend: switch loadArtistImages from concurrent fire-all to
sequential await loop. Each artist image loads one at a time,
images appear progressively as they resolve instead of all
failing from rate limit rejection.
2026-03-25 22:19:18 -04:00
yonlu 104e469774 fix: simplify artist image provider, remove unnecessary LookupArtist call
The previous implementation called LookupArtist (rate-limited MB API)
before fetching url-rels, wasting a rate limiter slot. The fetchURL
for rels also bypassed the MB rate limiter, risking 503 rejections.

Rewrite: fetch MB url-rels once (direct HTTP, cached 30 days), parse
both image and wikidata relations from the same response, resolve
Wikimedia thumb URL. No dependency on MusicBrainzClient — just the
Cache for storage and a plain http.Client.

Also cleared 34 stale cached empty results from previous failed
resolution attempts that were blocking image lookup.
2026-03-25 20:13:23 -04:00
yonlu 55473dd52a feat: artist images from MusicBrainz/Wikidata/Wikimedia Commons
Add ArtistImageProvider that resolves artist MBIDs to photo URLs:
1. MB url-rels 'image' type → extract Commons filename → thumb URL
2. MB url-rels 'wikidata' type → Wikidata P18 property → thumb URL
3. No image → falls back to initial-letter avatar

Wikimedia Commons thumb URLs constructed via MD5 hash bucketing
(standard Commons URL scheme). Results cached in explore_cache
with 30-day TTL — subsequent lookups are instant.

Frontend: search results and artist detail page show artist photos
in the circular avatar when available. Images load async and
replace the initial-letter fallback on arrival. Artist detail page
fires the image fetch alongside the other 4 parallel data loads.

Architecture supports adding more sources (fanart.tv, etc.) by
extending the resolve() method's source chain.
2026-03-25 19:55:10 -04:00
yonlu 0e376abfbb perf: batch thumbnail loading — one Wails call for all album art
Replace per-card GetThumbnail calls (10 round-trips) with a single
GetThumbnails batch call that fetches all visible album thumbnails
in one Wails bridge round-trip.

Backend GetThumbnails accepts []ThumbnailRequest and returns
map[mbid]→dataURL. Each request still checks library → disk cache
→ CAA in order, but the bridge overhead is 1 call instead of 10.

Frontend fires loadThumbnails() once after search results arrive.
Album cards render immediately with CAA URL fallback, then re-render
once the batch resolves with cached/local data URLs.
2026-03-25 19:37:46 -04:00
yonlu 0761cff408 feat: use local library cover art for search results
CoverArtProxy now checks three sources in order:
1. Local library (instant) — matches by album+artist name against
   the release_groups/cover_art tables. Albums the user already
   owns show their local cover art immediately.
2. Disk cache (instant) — previously fetched CAA thumbnails.
3. Cover Art Archive (network) — fetches and caches to disk.

Library index is built once on first access (sync.Once) from a
single SQL query joining release_groups → cover_art → artists.
Keyed by lowercased 'album\x00artist' for exact name matching.

GetThumbnail now takes (mbid, albumName, artistName) so the proxy
can check the library before falling back to CAA. Frontend passes
the album title and artist credit from the search result.
2026-03-25 19:00:29 -04:00
yonlu d5f34f242f fix: don't cache transient cover art failures as permanent misses
The CAA proxy was caching all fetch failures (including 503s and
timeouts) as empty files, treating them as permanent 'no art' misses.
During Internet Archive outages, this meant every album got cached
as having no cover art, and the cache persisted after IA recovered.

Now only 404 responses (no cover art exists) are cached as permanent
misses. 503, timeouts, and other transient errors are not cached,
so the next request retries the fetch.

Also cleared 33 incorrectly cached 0-byte miss files from a
concurrent IA outage.
2026-03-25 14:14:30 -04:00
yonlu 49a26c6163 feat: cover art proxy with disk cache for instant thumbnail loading
Add CoverArtProxy that fetches cover art from CAA, caches the image
bytes on disk (~/.local/share/yellowjacket/cover-art-cache/), and
returns base64 data URLs via the GetThumbnail Wails binding.

First load: fetches from CAA (rate-limited), caches to disk.
Subsequent loads: instant from disk cache, no network.
404s: cached as empty files to avoid re-fetching.

Frontend explore-view loads thumbnails async via GetThumbnail()
calls that fire during render. Cached thumbnails appear as data
URLs directly in img src, bypassing the browser's HTTP stack.
Uncached thumbnails fall back to the CAA URL while the proxy
fetches in the background, then re-render with the cached version.

Also stores caa_id and caa_release_mbid in the search index's
extra_json for future direct Internet Archive URL construction.
2026-03-25 14:09:30 -04:00
yonlu 1141febf29 perf: skip live LB popularity + cross-reference when index is ready
Phases 2 (3 LB popularity POST calls) and 3 (3 MB discography
browse calls) were adding ~3-6 seconds to every search through
rate-limited API calls. Now they only run as a fallback during
first launch before the search index is built.

Once the index is ready (after Tier 1, <5 seconds from startup):
  Phase 0: local FTS5 index query (instant)
  Phase 1: MB search (3 concurrent calls, ~1s)
  Phase 4: merge index hits (instant)
  Phase 5: filter and cap (instant)

Search drops from ~4-7s to ~1s. The index already carries
popularity data and covers discography cross-referencing,
making the live API calls redundant.
2026-03-25 13:55:26 -04:00
yonlu e4f4639ab7 feat: per-tier refresh intervals with incremental discography builds
Replace the single 7-day full rebuild with per-tier scheduling:

- Tier 1 (sitewide top lists): weekly refresh, 12 API calls
- Tiers 2-4 (discographies): monthly refresh, incremental —
  only fetches discographies for artists not already indexed

On subsequent runs:
- If T1 is fresh, load cached artists from the index (~0 calls)
- If discographies are fresh, skip Tiers 2-4 entirely (~0 calls)
- If discographies are stale, diff against indexed set and only
  fetch new artists that appeared in the sitewide lists

Add helpers: isMetaFresh (per-key freshness check),
loadCachedSitewideArtists (read artists from existing index),
filterUnindexed (diff artist list against indexed set).

After first build: typical startup is <5s (T1 cache load).
Monthly incremental: ~50-100 calls for newly appeared artists.
2026-03-25 13:43:00 -04:00
yonlu 59d5b7272e feat: popularity-scaled per-artist index budgets
Instead of fixed 20 RGs + 100 recordings for every artist, scale
the budget by popularity using a power curve (exponent 0.3):

  Radiohead (2.5M listens): 20 RGs, 100 recordings
  Hans Zimmer (715K):       15 RGs, 71 recordings
  Clutch (178K):            11 RGs, 50 recordings
  Similar (~10K):            7 RGs, 27 recordings
  Organic (unknown):         5 RGs, 10 recordings

Saves ~53% index size (~29 MB vs ~62 MB) with identical API calls.
The savings come from T4 similar artists (long tail) where full
discographies were wasteful. Top artists still get full coverage.
2026-03-25 13:11:15 -04:00
yonlu 48acede1bc feat: 5-tier search index with library + similar artist expansion
Rewrite SearchIndex with tiered background build:

Tier 1 — Sitewide instant (<5s, 12 calls): top artists, recordings,
  and release groups across 4 time ranges. Searchable immediately.

Tier 2 — Sitewide full discog (~16min, 2881 calls): top 20 RGs +
  top 100 recordings per sitewide artist (~1440 unique artists from
  all_time/this_year/this_month/this_week union).

Tier 3 — Library artists (~4min, 664 calls): match local library
  artist names against known MBIDs, index their full discographies.
  Catches the user's personal taste that sitewide misses.

Tier 4 — Similar artists (~24min, ~4300 calls): fetch similar
  artists from LB labs for each library artist, index their
  discographies. Fans out into the user's taste neighborhood.

Tier 5 — Organic growth (0 calls): BrowseReleaseGroups now writes
  to the search index in a background goroutine. Every artist page
  view adds that artist's discography to the index for free.

Other changes:
- indexRGsPerArtist bumped 10→20 (96% vs 88% coverage)
- indexRecsPerArtist bumped 10→100 (track-name searchability)
- indexMinPopularity = 50 (cuts noise from long tails)
- Dedicated 3 req/s rate limiter for indexer
- Labs similar-artists endpoint at labs.api.listenbrainz.org
- Each tier marks index as ready on completion so search improves
  progressively during the ~44min total build
2026-03-25 12:49:20 -04:00
yonlu cf40909313 feat: wire SearchIndex into Search() — Phase 0 index query + merge
Create SearchIndex in NewExploreService, start background build in
SetContext (on app startup). Search() now has 6 phases:

  Phase 0: query local FTS5 index (instant, no API calls)
  Phase 1: concurrent MB search
  Phase 2: LB popularity boost
  Phase 3: cross-reference artist discographies
  Phase 4: merge index hits (prepend new entries, dedup by MBID)
  Phase 5: filter and cap

Index hits for release groups/recordings not already in MB results
are prepended so popular albums surface even when MB search can't
find them. scalePopularity() maps raw listen counts to 0-100 scores
via log scaling for compatibility with the blended score system.
2026-03-25 09:41:55 -04:00
yonlu bba4e1f3de feat: SearchIndex — background build + FTS5 query for popularity index
New SearchIndex struct in searchindex.go:
- Background build fetches top 1000 LB artists, then their top 10
  release groups and top 10 recordings (2001 API calls total)
- Dedicated 3 req/s rate limiter for indexer (LB allows 30/10s)
- Bounded concurrency (3 goroutines) with progress logging
- Batch INSERTs in transactions of 100 rows
- FTS5 query with prefix matching ('for you' → 'for* you*')
- Results sorted by popularity descending
- Skips rebuild if index is < 7 days old
- Marks index ready from existing rows if build fails
- Context cancellation for clean shutdown
2026-03-25 09:39:22 -04:00
yonlu 57a07e96cb feat: migration 12 — explore_index + FTS5 search index schema
Add explore_index table (entity_type, mbid, title, artist_name,
artist_mbid, popularity, extra_json) with a unique index on
(entity_type, mbid). FTS5 virtual table explore_index_fts backed
by the content table with auto-sync triggers for insert/update/delete.
explore_index_meta table tracks build timestamps.
2026-03-25 09:35:35 -04:00
yonlu ba6185adc3 feat: cross-reference search — match query against top artists' discographies
After MB search + popularity reranking, browse the discographies of
the top 3 artists and fuzzy-match the full query against album titles.
Matching albums not already in results are injected at the front.

Fuzzy matching uses substring containment with word-level ratio
(handles 'for you tatsuro' → 'FOR YOU' at 0.667) and word overlap
as fallback. Threshold: 0.4 ratio.

Example: 'for you tatsuro' now finds FOR YOU by 山下達郎 even though
MB text search treats 'for' and 'you' as stop words and never
returns it. The album is found via Yamashita's cached discography.
2026-03-24 22:53:47 -04:00
yonlu f2a703ed52 feat: show English alias for non-Latin script artists
Extract primary English alias from MusicBrainz artist data when the
canonical name uses non-Latin script (CJK, Cyrillic, etc.). Display
it as the primary name in search results and artist detail header,
with the native script name as a subtitle beneath.

Example: 山下達郎 now shows 'Tatsuro Yamashita' prominently with
'山下達郎' as a subtitle. Artists with Latin names are unchanged.
2026-03-24 22:47:39 -04:00
yonlu 1e95ddee68 feat: score threshold + server-side result caps
Drop artists and recordings with blended score < 25 after popularity
reranking. Cap each entity slice to 15 server-side. Request 20 from
MB to allow filtering headroom. Reduces payload size and noise.
2026-03-24 22:42:54 -04:00
yonlu 5f8f6d6a26 feat: min 2-char query gate, result caps at 10 per section
Don't fire search for single-character queries — show 'Keep typing…'
instead. Cap rendered results at 10 per section (artists, albums,
tracks) to reduce noise. Top results already capped at 3.
2026-03-24 22:41:02 -04:00
yonlu 176ac26f91 feat: popularity-boosted search reranking via ListenBrainz
After MB search returns text-relevance-scored results, fetch bulk
popularity data from ListenBrainz (POST /1/popularity/{artist,
recording,release-group}) for all result MBIDs. Blend scores:

  final = 0.6 * mb_relevance + 0.4 * log10_popularity

Log-scale normalization ensures massive artists don't drown out
everything, but popular results rise above obscure exact matches.
Release groups (no MB score) sort by raw popularity.

Three LB POST calls run concurrently — each hits a different
endpoint. All are rate-limited and cached (24h TTL).

Example: searching 'tatsuro' now ranks Tatsuro Yamashita (2.5M LB
listens, score 97) above 'tatsuro' vocaloid producer (4 listens,
score 64) despite the latter being an exact name match on MB.
2026-03-24 22:07:16 -04:00
yonlu 4ddd252e1e fix: don't double-pluralize Albums/Other Albums section headers 2026-03-24 21:43:35 -04:00
yonlu 450f6002a2 feat: split artist discography into Albums vs Other Albums
Use MusicBrainz secondaryTypes to distinguish studio albums from
compilations, soundtracks, live albums, remixes, etc. Albums with
no non-studio secondary types show under 'Albums'; everything else
under 'Other Albums'. EPs and Singles remain their own sections.

Section order: Albums → EP → Single → Other Albums → ...rest.
2026-03-24 21:40:25 -04:00
yonlu eabcf8395e fix: unmarshal LB top recordings from snake_case wire format, cap at 10
The ListenBrainz popularity API returns snake_case JSON fields
(recording_name, artist_name, total_listen_count, recording_mbid)
but LBTopRecording used camelCase JSON tags for Wails serialization.
All fields silently deserialized as zero values — empty strings and
zero counts — producing ~8000 blank rows in the top tracks section.

Fix: add lbTopRecordingWire with snake_case tags for API unmarshal,
convert to LBTopRecording (camelCase) for Wails. Cap results at 10
to avoid rendering thousands of rows for prolific artists.
2026-03-24 19:35:34 -04:00
yonlu a62b1da474 feat(S04/T02): Built the explore-album-details Lit component with relea…
- frontend/src/components/explore-album-details/explore-album-details.ts
2026-03-24 15:02:15 -04:00
yonlu 67e1917f5a feat(S04/T01): Added DiscNumber field to MBTrack (populated from Medium…
- backend/explore/types.go
- backend/explore/musicbrainz.go
- frontend/wailsjs/go/explore/Service.d.ts
- frontend/index.ts
2026-03-24 14:29:01 -04:00
yonlu bb271e86f5 feat(S03/T02): Add similar artists horizontal scroll section with click…
- frontend/src/components/explore-artist-details/explore-artist-details.ts
2026-03-24 13:43:22 -04:00
yonlu 6ee16c7a87 feat(S03/T01): Add explore-artist-details Lit component with artist hea…
- frontend/src/components/explore-artist-details/explore-artist-details.ts
- frontend/index.ts
2026-03-23 23:33:27 -04:00
yonlu 5451e70a0c chore(M004/S02): auto-commit after complete-slice 2026-03-23 17:45:08 -04:00
yonlu 677ed01d89 feat(S02/T01): Added CoverArtGroupURL for release-group cover art, conc…
- backend/explore/coverart.go
- backend/explore/explore.go
- backend/explore/coverart_test.go
- frontend/wailsjs/go/explore/Service.js
- frontend/wailsjs/go/explore/Service.d.ts
2026-03-23 17:31:01 -04:00
yonlu 1ff53d8084 chore(M004/S01): auto-commit after complete-slice 2026-03-23 16:55:22 -04:00
yonlu 6cc9acbc99 perf(S01/T02): Add MusicBrainz search/lookup/browse client, ListenBrain…
- backend/explore/types.go
- backend/explore/musicbrainz.go
- backend/explore/listenbrainz.go
- backend/explore/coverart.go
- backend/explore/coverart_test.go
2026-03-23 14:03:51 -04:00
yonlu 8fc075c24a perf(S01/T01): Add token-bucket rate limiter (1 req/sec), SQLite respon…
- backend/explore/ratelimiter.go
- backend/explore/cache.go
- backend/database/sql/schemas/explore_cache.sql
- backend/database/database.go
2026-03-23 07:55:25 -04:00
yonlu 398fd5aaae chore(M004/S01): auto-commit after state-rebuild 2026-03-22 23:25:58 -04:00
yonlu 916b5ef753 fix: add missing playCount and lastPlayed args to mapTrackRow test calls
The mapTrackRow signature was extended with playCount and lastPlayed
fields in the play history feature, but the scan_test.go callers
were not updated, breaking go vet and golangci-lint.
2026-03-22 11:10:59 -04:00
yonlu f7ca138296 fix: flush BufferedStreamer ring buffer on seek to prevent stale audio
When seeking, the underlying decoder position was updated but the
BufferedStreamer's ring buffer still contained up to 2 seconds of
pre-seek audio data. The speaker would drain this stale buffer
before playing audio from the new position, causing an audible
delay where the old position's audio continued playing.

Add a Flush() method to BufferedStreamer that resets the ring buffer
pointers, and call it in seekLocked() immediately after a successful
seek. This ensures the speaker starts playing from the seeked
position without any stale audio artifact.
2026-03-22 11:10:53 -04:00
yonlu 3173b78a87 chore(Q1): auto-commit after quick-task 2026-03-22 11:07:17 -04:00
yonlu c7bf5271e6 fix: simplify play count accessor, remove right-align to debug rendering 2026-03-22 10:52:58 -04:00
yonlu c5b293d410 chore: update sqlcgen models with play_count/last_played fields
Auto-generated by sqlc from updated schema. Adds PlayCount/LastPlayed
to AudioFile and TrackMetadatum structs, and PlayHistory model.
2026-03-22 10:48:35 -04:00
yonlu 7cde7cc7b8 fix: play count display and live update after playback
- Column accessor shows '0' instead of empty string for unplayed tracks
- recordPlay emits TrackMetadataChanged event after updating DB
- Frontend library store invalidates on that event, refreshing play counts
2026-03-22 09:42:18 -04:00
yonlu e1bfe12903 fix: register playCount column in backend AllColumnIDs + rename to 'Play Count'
Backend validation rejected 'playCount' as unknown column ID, silently
preventing it from being enabled or persisted. Added ColPlayCount to
the tracklist package's constant list and AllColumnIDs slice.
2026-03-21 22:53:52 -04:00
yonlu 2db6e09aa3 fix: use sql.NullTime for last_played to handle NULL scan
COALESCE(last_played, '') returned empty string which can't scan into
time.Time. Removed COALESCE, use sql.NullTime instead. Format to string
only when Valid.
2026-03-21 15:40:34 -04:00
yonlu b643aee5a3 fix: mapTrackRow accepts time.Time for LastPlayed (matches sqlcgen Row type) 2026-03-21 15:36:59 -04:00
yonlu 2ee157a376 feat(M003/S03): play count column + data pipeline
Backend:
- GetAllTracksWithFullMetadata queries now select play_count and last_played
- mapTrackRow accepts and passes through PlayCount/LastPlayed
- PlayCount + LastPlayed added to library.Track struct
- sqlcgen Row types updated with new fields

Frontend:
- PlayCount + LastPlayed added to library.Track TypeScript model
- 'Plays' column added to track-list column definitions (60px, right-aligned, sortable)

Queries without play data (search, genre, album) pass 0/empty defaults.
2026-03-21 15:33:08 -04:00
yonlu 9c85cfcc7b feat(M003/S02): smart playlist integration — play_count and days_since_played fields
Backend:
- Added play_count and days_since_played to rule engine field whitelist
- days_since_played uses julianday() expression with COALESCE for NULL handling
- Never-played tracks (NULL last_played) match 'greater_than' but not 'less_than'
- Added PlayCount + LastPlayed to library.Track struct
- Evaluate query selects play_count and last_played from track_metadata
- Added play_count to sort field options

Frontend:
- Added play_count and days_since_played to field and numeric field lists
- Added play_count to sort options

All 49 rule engine + 15 service tests pass unchanged.
2026-03-21 15:26:43 -04:00
yonlu 9bf2bbab2e feat(M003/S01): play history tracking — schema, migration, recording hook
Migration 10:
- play_history table (audio_file_id FK, played_at DATETIME, CASCADE delete)
- play_count + last_played columns on audio_files (denormalized)
- Recreated track_metadata VIEW with play_count and last_played columns

Play recording:
- queue.recordPlay() inserts play_history row + updates denormalized columns
- Called from OnPlaybackFinished after queue advance completes
- Mutex released before DB write to avoid MaxOpenConns(1) deadlock
- Natural finish only — skip/stop does not count

Tests:
- TestMigration10PlayHistory: schema, columns, VIEW, round-trip verification
- All 49 smart playlist + 15 service + existing DB tests still pass
2026-03-21 15:23:18 -04:00
yonlu ebaa856e0c fix: align combobox height to operator select via grid stretch
- Grid rows use align-items: stretch so all cells share the select's height
- Combobox host, wrapper, and input all set height: 100% to fill the cell
- Remove button uses align-self: center to stay centered instead of stretching
2026-03-21 13:46:26 -04:00