Basic search: visual overhaul + per-source picker in hybrid mode

Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.

VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
  primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
  pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
  (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
  wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
  52px brand-tinted cover icon, chevron expand indicator, pill
  action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
  pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
  with the accent treatment
- Responsive: action button columns stack vertically below 900px

New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.

Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.

SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
  active sources from the orchestrator's chain (or the single
  active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
  to target that source for the next search; the chip's brand
  accent fills.
- In single-source mode the lone chip is rendered as a dashed-
  border label so the user always knows what they're searching
  but can't accidentally try to switch to sources that aren't
  configured.
- ``/api/search`` accepts an optional ``source`` body param. When
  set, ``core/search/basic.py:run_basic_search`` resolves the
  client directly via ``orchestrator.client(source)`` and calls
  its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
  original ``orchestrator.search()`` call exactly as before.
  Unknown source names also fall back to the default — typo
  protection.

TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly

101 search-suite tests pass.
This commit is contained in:
Broque Thomas 2026-05-28 10:22:07 -07:00
parent 466a68dc4d
commit 7145368d42
8 changed files with 1029 additions and 104 deletions

View file

@ -1,28 +1,59 @@
"""Basic Soulseek file search — flat list of file results sorted by quality. """Basic download-source file search — flat list of file results sorted by quality.
Used by the Soulseek source icon in the unified search UI and by direct Used by the basic search UI on the Search page and by ``/api/search``.
/api/search calls. Synchronous wrapper around the async soulseek client.
``run_basic_search`` replaced ``run_basic_soulseek_search`` so the caller
can target any active download source (not just slskd). The old name is
kept as a thin alias for backwards compat with any callers outside this
module.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Callable from typing import Callable, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_basic_soulseek_search( def run_basic_search(
query: str, query: str,
download_orchestrator, download_orchestrator,
run_async: Callable, run_async: Callable,
*,
source: Optional[str] = None,
) -> list[dict]: ) -> list[dict]:
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list. """Search ``source`` (or the active/first hybrid source) for ``query``.
Returns dicts with `result_type` set to "album" or "track" and sorted by Returns dicts with ``result_type`` set to ``"album"`` or ``"track"``
`quality_score` descending. Empty list on any failure (caller logs). and sorted by ``quality_score`` descending. Empty list on any failure.
Parameters
----------
source:
Optional source name to override the orchestrator's default selection.
Must be a canonical name from ``DownloadPluginRegistry`` (e.g.
``"soulseek"``, ``"tidal"``, ``"qobuz"``). When ``None``, behaviour
is unchanged from before: orchestrator.search() picks the active
source (single mode) or the first in chain (hybrid).
""" """
tracks, albums = run_async(download_orchestrator.search(query)) if source and download_orchestrator:
# Target a specific source: resolve the client and call search()
# directly instead of going through the orchestrator chain.
try:
client = download_orchestrator.client(source)
except Exception as exc:
logger.warning("basic search: could not resolve client for %r: %s", source, exc)
client = None
if client is None:
logger.warning("basic search: no client for source %r — falling back to orchestrator", source)
tracks, albums = run_async(download_orchestrator.search(query))
else:
logger.info("basic search: targeting %r for %r", source, query)
tracks, albums = run_async(client.search(query))
else:
tracks, albums = run_async(download_orchestrator.search(query))
processed_albums = [] processed_albums = []
for album in albums: for album in albums:
@ -42,3 +73,7 @@ def run_basic_soulseek_search(
key=lambda x: x.get('quality_score', 0), key=lambda x: x.get('quality_score', 0),
reverse=True, reverse=True,
) )
# Backwards-compat alias for any callers that haven't been updated yet.
run_basic_soulseek_search = run_basic_search

View file

@ -96,3 +96,123 @@ def test_missing_quality_score_treated_as_zero():
result = basic.run_basic_soulseek_search('q', client, _sync_run_async) result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
# has_score (0.5) ranks above no_score (treated as 0) # has_score (0.5) ranks above no_score (treated as 0)
assert result[0]['name'] == 'h' assert result[0]['name'] == 'h'
# ── Source-targeted search (new in basic-search redesign) ──────────────
class _FakeOrchestratorMulti:
"""Orchestrator stand-in with per-source clients.
``search()`` is the default orchestrator path (single-source mode or
first hybrid source). ``client(name)`` returns the per-source client
so a source-targeted basic search can call ``.search()`` directly on
the specific source rather than going through the chain.
"""
def __init__(self, default_results, per_source_results, fail_unknown=False):
self._default = default_results
self._sources = per_source_results
self._fail_unknown = fail_unknown
self.default_search_calls = 0
self.per_source_calls = {}
async def search(self, query, timeout=None, progress_callback=None, exclude_sources=None):
self.default_search_calls += 1
return self._default
def client(self, name):
if name not in self._sources:
if self._fail_unknown:
return None
return None
plugin = _FakeSoulseek(tracks=self._sources[name][0], albums=self._sources[name][1])
# Record which sources got asked for.
self.per_source_calls.setdefault(name, 0)
self.per_source_calls[name] += 1
return plugin
def test_source_param_routes_to_specific_client():
"""``source='tidal'`` calls the Tidal client directly, NOT the
orchestrator's chain. Ensures the per-source basic search bypasses
the hybrid-first selection so users can target any active source."""
tidal_track = _SearchTrack('From Tidal', 0.9, username='tidal')
soul_track = _SearchTrack('From Soulseek', 0.5, username='peer')
orch = _FakeOrchestratorMulti(
default_results=([soul_track], []),
per_source_results={
'tidal': ([tidal_track], []),
'soulseek': ([soul_track], []),
},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
# Tidal result returned, NOT soulseek result.
assert len(result) == 1
assert result[0]['name'] == 'From Tidal'
# Orchestrator default chain NOT consulted.
assert orch.default_search_calls == 0
# Tidal client was called exactly once.
assert orch.per_source_calls.get('tidal') == 1
def test_no_source_param_falls_through_to_orchestrator_default():
"""When ``source`` is omitted, behaviour is identical to pre-redesign:
orchestrator.search() is called and routes to the configured source
(single-source mode) or first hybrid source. Preserves the existing
contract for callers that don't opt in to per-source targeting."""
track = _SearchTrack('Default', 0.7)
orch = _FakeOrchestratorMulti(
default_results=([track], []),
per_source_results={'tidal': ([], [])},
)
result = basic.run_basic_search('q', orch, _sync_run_async)
assert result[0]['name'] == 'Default'
assert orch.default_search_calls == 1
assert orch.per_source_calls == {}
def test_unknown_source_falls_back_to_orchestrator():
"""Bogus source name (e.g. user-edited config with a typo) falls
through to the orchestrator default rather than returning an empty
list silently. Logged as a warning so misconfiguration is visible."""
track = _SearchTrack('Default', 0.7)
orch = _FakeOrchestratorMulti(
default_results=([track], []),
per_source_results={'tidal': ([], [])},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='nonexistent')
assert result[0]['name'] == 'Default'
assert orch.default_search_calls == 1
def test_backwards_compat_alias_still_works():
"""``run_basic_soulseek_search`` is the legacy name; any external
caller that hasn't been updated must keep working. Aliased to
``run_basic_search`` so both call the same code path."""
track = _SearchTrack('Compat', 0.5)
client = _FakeSoulseek(tracks=[track])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result[0]['name'] == 'Compat'
assert basic.run_basic_soulseek_search is basic.run_basic_search
def test_source_targeted_search_serialises_albums_with_tracks():
"""Source-targeted path goes through the same normaliser as the
default path, so albums returned via a specific source still get
their tracks serialised + ``result_type='album'`` tagged."""
inner = _SearchTrack('inner', 0.5, filename='in.mp3')
album = _SearchAlbum('TidalAlbum', 0.9, tracks=[inner], username='tidal')
orch = _FakeOrchestratorMulti(
default_results=([], []),
per_source_results={'tidal': ([], [album])},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
assert result[0]['result_type'] == 'album'
assert result[0]['tracks'][0]['name'] == 'inner'

View file

@ -5536,19 +5536,50 @@ def _build_search_deps():
) )
@app.route('/api/search/sources', methods=['GET'])
def search_sources():
"""Return the list of active download sources available for basic search.
In single-source mode returns that one source. In hybrid mode returns
every source in the configured chain so the frontend can render a
source-picker chip row and let the user search specific sources.
Response shape: ``{"mode": "hybrid"|<source>, "sources": [{"name": str,
"display_name": str}]}``
"""
if not download_orchestrator:
return jsonify({"mode": "soulseek", "sources": [{"name": "soulseek", "display_name": "Soulseek"}]})
mode = download_orchestrator.mode
if mode == 'hybrid':
chain = download_orchestrator._resolve_source_chain()
sources = [
{"name": s, "display_name": download_orchestrator.registry.display_name(s)}
for s in chain
]
else:
sources = [{"name": mode, "display_name": download_orchestrator.registry.display_name(mode)}]
return jsonify({"mode": mode, "sources": sources})
@app.route('/api/search', methods=['POST']) @app.route('/api/search', methods=['POST'])
def search_music(): def search_music():
"""Basic Soulseek file search.""" """Basic download-source file search.
Accepts an optional ``source`` body param to target a specific source
in hybrid mode (e.g. ``"soulseek"``, ``"tidal"``). When omitted, uses
the active source (single-source mode) or the first hybrid source.
"""
data = request.get_json() data = request.get_json()
query = data.get('query') query = data.get('query')
if not query: if not query:
return jsonify({"error": "No search query provided."}), 400 return jsonify({"error": "No search query provided."}), 400
logger.info(f"Web UI Search initiated for: '{query}'") requested_source = (data.get('source') or '').strip().lower() or None
logger.info(f"Web UI Search initiated for: '{query}'" + (f" (source={requested_source})" if requested_source else ""))
add_activity_item("", "Search Started", f"'{query}'", "Now") add_activity_item("", "Search Started", f"'{query}'", "Now")
try: try:
results = _search_basic.run_basic_soulseek_search(query, download_orchestrator, run_async) results = _search_basic.run_basic_search(query, download_orchestrator, run_async, source=requested_source)
add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now") add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now")
return jsonify({"results": results}) return jsonify({"results": results})
except Exception as e: except Exception as e:

View file

@ -10,6 +10,7 @@
<meta name="theme-color" content="#1db954"> <meta name="theme-color" content="#1db954">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
{{ vite_assets('head')|safe }} {{ vite_assets('head')|safe }}
@ -2088,103 +2089,71 @@
results are cached per (query, source) pair. --> results are cached per (query, source) pair. -->
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div> <div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<!-- Basic Search Section (Current) --> <!-- Basic Search Section -->
<div id="basic-search-section" class="search-section"> <div id="basic-search-section" class="search-section">
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container"> <!-- Source picker: chip row, one chip per active download source.
<input type="text" id="downloads-search-input" Populated by downloads.js:initBasicSearchSources().
placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')"> Hidden until sources load; in single-source mode shows one
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button> non-interactive chip so the user knows what they're searching. -->
<button id="downloads-search-btn">🔍 Search</button> <div class="bs-source-row" id="bs-source-row" aria-label="Search source" role="tablist"></div>
<!-- Search bar -->
<div class="bs-search-bar">
<div class="bs-search-input-wrap">
<svg class="bs-search-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="M15 15l3 3"/></svg>
<input type="text" id="downloads-search-input"
placeholder="Search artists, albums, tracks…"
autocomplete="off" spellcheck="false">
<button id="downloads-cancel-btn" class="bs-cancel-btn hidden" aria-label="Cancel"></button>
</div>
<button id="downloads-search-btn" class="bs-search-btn">Search</button>
</div> </div>
<!-- Status / loading bar -->
<div id="filters-container" class="filters-container hidden"> <div class="bs-status-bar">
<div class="filter-toggle-header">
<button id="filter-toggle-btn" class="filter-toggle-btn">⏷ Filters</button>
</div>
<div id="filter-content" class="filter-content hidden">
<!-- Filter by Type -->
<div class="filter-group">
<label class="filter-label">Type:</label>
<button class="filter-btn active" data-filter-type="type"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="type"
data-value="album">Albums</button>
<button class="filter-btn" data-filter-type="type"
data-value="track">Singles</button>
</div>
<!-- Filter by Format -->
<div class="filter-group">
<label class="filter-label">Format:</label>
<button class="filter-btn active" data-filter-type="format"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="format"
data-value="flac">FLAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="mp3">MP3</button>
<!-- Added missing format buttons -->
<button class="filter-btn" data-filter-type="format"
data-value="ogg">OGG</button>
<button class="filter-btn" data-filter-type="format"
data-value="aac">AAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="wma">WMA</button>
</div>
<!-- Sort Controls -->
<div class="filter-group">
<label class="filter-label">Sort by:</label>
<button id="sort-order-btn" class="filter-btn sort-order-btn"
data-order="desc">↓</button>
<!-- Added all sort options from the GUI -->
<button class="filter-btn active" data-filter-type="sort"
data-value="relevance">Relevance</button>
<button class="filter-btn" data-filter-type="sort"
data-value="quality_score">Quality</button>
<button class="filter-btn" data-filter-type="sort"
data-value="size">Size</button>
<button class="filter-btn" data-filter-type="sort"
data-value="title">Name</button>
<button class="filter-btn" data-filter-type="sort"
data-value="username">Uploader</button>
<button class="filter-btn" data-filter-type="sort"
data-value="bitrate">Bitrate</button>
<button class="filter-btn" data-filter-type="sort"
data-value="duration">Duration</button>
<button class="filter-btn" data-filter-type="sort"
data-value="availability">Available</button>
<button class="filter-btn" data-filter-type="sort"
data-value="upload_speed">Speed</button>
</div>
</div>
</div>
<!-- Search Status Bar -->
<div class="search-status-container">
<div class="spinner-animation hidden"></div> <div class="spinner-animation hidden"></div>
<p id="search-status-text">Ready to search • Enter artist, song, or album name</p> <span id="search-status-text" class="bs-status-text">Enter an artist, album, or track name to search</span>
<div class="dots-animation hidden"></div> <div class="dots-animation hidden"></div>
</div> </div>
<!-- Search Results Area: Replicates the QScrollArea --> <!-- Filters: always-visible compact pill row -->
<div class="search-results-container"> <div id="filters-container" class="bs-filters hidden">
<div class="search-results-header"> <div class="bs-filter-group">
<h3>Search Results</h3> <span class="bs-filter-label">Type</span>
<button class="filter-btn bs-filter-pill active" data-filter-type="type" data-value="all">All</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="album">Albums</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="track">Tracks</button>
</div> </div>
<div class="search-results-scroll-area" id="search-results-area"> <div class="bs-filter-group">
<!-- <span class="bs-filter-label">Format</span>
The placeholder search results have been removed. <button class="filter-btn bs-filter-pill active" data-filter-type="format" data-value="all">All</button>
This area will now be populated by JavaScript based on API responses. <button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="flac">FLAC</button>
--> <button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="mp3">MP3</button>
<div class="search-results-placeholder"> <button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="ogg">OGG</button>
<p>Your search results will appear here.</p> <button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="aac">AAC</button>
</div> <button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="wma">WMA</button>
</div>
<div class="bs-filter-group">
<span class="bs-filter-label">Sort</span>
<button id="sort-order-btn" class="filter-btn bs-filter-pill sort-order-btn" data-order="desc"></button>
<button class="filter-btn bs-filter-pill active" data-filter-type="sort" data-value="relevance">Relevance</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="quality_score">Quality</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="size">Size</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="title">Name</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="username">Uploader</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="bitrate">Bitrate</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="duration">Duration</button>
</div> </div>
</div> </div>
<!-- Results area -->
<div class="bs-results-wrap" id="search-results-area">
<div class="search-results-placeholder">
<p>Enter a search term to get started.</p>
</div>
</div>
</div> </div>
<!-- End Basic Search Section --> <!-- End Basic Search Section -->

View file

@ -0,0 +1,694 @@
/* =====================================================================
* Basic Search visual redesign (functional behaviour untouched)
*
* Self-contained sheet appended via index.html link. Targets the new
* markup (``bs-*`` prefixed classes). Existing
* ``.search-bar-container`` / ``.filter-btn`` / ``.search-results-container``
* rules earlier in style.css still apply elsewhere (other search UIs reuse
* them); they no longer match the new basic-search markup so this redesign
* is fully scoped.
* ===================================================================== */
/* Source chip row above the search bar. One chip per active hybrid
* source; in single-source mode the only chip is rendered with the
* ``.single`` modifier so it reads as a label rather than a control. */
.bs-source-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
align-items: center;
min-height: 32px;
}
.bs-source-row:empty {
display: none;
}
.bs-source-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(0, 0, 0, 0.2) 100%);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 999px;
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bs-source-chip:hover:not(.active):not(.single):not(:disabled) {
border-color: rgba(var(--accent-rgb), 0.35);
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.08) 0%,
rgba(0, 0, 0, 0.2) 100%);
color: #fff;
transform: translateY(-1px);
}
.bs-source-chip.active {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
border-color: rgba(var(--accent-rgb), 0.55);
color: #fff;
box-shadow:
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.bs-source-chip.single {
cursor: default;
border-style: dashed;
border-color: rgba(var(--accent-rgb), 0.35);
background: transparent;
color: rgb(var(--accent-light-rgb));
}
/* — Search bar: glass card matching the dashboard / auto-sync vibe. */
.bs-search-bar {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background:
radial-gradient(ellipse at 0% 0%,
color-mix(in srgb, rgb(var(--accent-rgb)) 6%, transparent) 0%,
transparent 60%),
linear-gradient(160deg,
rgba(22, 25, 36, 0.7) 0%,
rgba(14, 16, 24, 0.85) 100%);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 16px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 6px 20px rgba(0, 0, 0, 0.25);
margin-bottom: 12px;
}
.bs-search-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
height: 42px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.bs-search-input-wrap:focus-within {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
}
.bs-search-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.4);
flex-shrink: 0;
}
.bs-search-input-wrap:focus-within .bs-search-icon {
color: rgb(var(--accent-light-rgb));
}
#basic-search-section #downloads-search-input {
flex: 1;
border: none;
background: transparent;
color: #fff;
font-size: 14px;
font-weight: 500;
outline: none;
padding: 0;
height: 100%;
}
#basic-search-section #downloads-search-input::placeholder {
color: rgba(255, 255, 255, 0.32);
}
.bs-cancel-btn {
width: 24px;
height: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
cursor: pointer;
font-size: 13px;
line-height: 1;
transition: all 0.2s;
flex-shrink: 0;
padding: 0;
}
.bs-cancel-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fff;
}
.bs-search-btn {
padding: 0 22px;
height: 42px;
border: 1px solid rgba(var(--accent-rgb), 0.55);
border-radius: 12px;
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.bs-search-btn:hover {
transform: translateY(-1px);
box-shadow:
0 6px 18px color-mix(in srgb, rgb(var(--accent-rgb)) 40%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.bs-search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* — Status bar: thin accent-tinted pill matching the dashboard vibe. */
.bs-status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background:
linear-gradient(180deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 4%, transparent),
transparent);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
margin-bottom: 12px;
}
.bs-status-text {
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
flex: 1;
}
/* — Filters: compact pill row, always visible after first search. */
.bs-filters {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
padding: 12px 14px;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.025) 0%,
rgba(0, 0, 0, 0.15) 100%);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
margin-bottom: 12px;
}
.bs-filter-group {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.bs-filter-label {
color: rgba(255, 255, 255, 0.4);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-right: 4px;
}
.bs-filter-pill {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 99px;
color: rgba(255, 255, 255, 0.55);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.18s ease;
}
.bs-filter-pill:hover:not(.active) {
border-color: rgba(var(--accent-rgb), 0.35);
color: #fff;
}
.bs-filter-pill.active {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 80%, white 20%),
rgb(var(--accent-rgb)));
border-color: rgba(var(--accent-rgb), 0.55);
color: #fff;
box-shadow:
0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
/* — Results area: glass surface for cards. */
.bs-results-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bs-results-wrap .search-results-placeholder {
text-align: center;
padding: 60px 30px;
color: rgba(255, 255, 255, 0.35);
font-size: 13px;
}
/* Album result card: glass + accent left-edge stripe + cover icon.
* ``!important`` is used liberally below because the original
* ``.album-result-card`` / ``.album-icon`` / etc. rules in style.css
* (~7247 onwards) are unscoped and apply heavyweight styles (24px
* padding, 56px icon, 24px border-radius, big box-shadows) that
* collide with this redesign. Scoping with ``#basic-search-section``
* wins on specificity for some properties but the original
* ``box-shadow`` and ``padding`` rules need explicit override to
* defeat the cascade interaction with hover / pseudo states. */
#basic-search-section .album-result-card {
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(0, 0, 0, 0.2) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 14px !important;
position: relative;
transition: border-color 0.2s, transform 0.2s !important;
margin: 0 !important;
padding: 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 2px 10px rgba(0, 0, 0, 0.2) !important;
transform: none !important;
}
#basic-search-section .album-result-card::before {
content: '';
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 3px;
background: linear-gradient(180deg,
rgb(var(--accent-light-rgb)),
rgb(var(--accent-rgb)));
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px color-mix(in srgb, rgb(var(--accent-rgb)) 50%, transparent);
z-index: 2;
}
#basic-search-section .album-result-card:hover {
border-color: rgba(var(--accent-rgb), 0.35) !important;
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.06) 0%,
rgba(0, 0, 0, 0.2) 100%) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 6px 20px rgba(0, 0, 0, 0.3) !important;
transform: translateY(-1px) !important;
}
#basic-search-section .album-card-header {
display: flex !important;
align-items: center !important;
gap: 16px !important;
padding: 18px 22px !important;
cursor: pointer;
}
#basic-search-section .album-expand-indicator {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
transition: transform 0.25s ease;
flex-shrink: 0;
width: 16px;
text-align: center;
}
#basic-search-section .album-result-card.expanded .album-expand-indicator {
transform: rotate(90deg);
}
#basic-search-section .album-icon {
width: 52px !important;
height: 52px !important;
min-width: 52px;
border-radius: 10px !important;
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent),
rgba(0, 0, 0, 0.4)) !important;
border: 1px solid rgba(var(--accent-rgb), 0.3) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 26px !important;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
color: rgba(var(--accent-rgb), 1.0);
}
#basic-search-section .album-info {
flex: 1 1 auto !important;
min-width: 0;
display: block;
}
#basic-search-section .album-title {
color: rgba(255, 255, 255, 0.95) !important;
font-size: 15px !important;
font-weight: 700 !important;
letter-spacing: -0.005em;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 4px 0 !important;
}
#basic-search-section .album-artist {
color: rgba(255, 255, 255, 0.65) !important;
font-size: 13px !important;
font-weight: 500;
line-height: 1.3;
margin: 0 0 6px 0 !important;
}
#basic-search-section .album-details {
color: rgb(var(--accent-light-rgb)) !important;
font-size: 11px !important;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.3;
margin: 0 0 3px 0 !important;
}
#basic-search-section .album-uploader {
color: rgba(255, 255, 255, 0.4) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 !important;
}
#basic-search-section .album-actions {
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
flex-shrink: 0;
}
/* — Track result card: glass row, similar height to album cards. */
#basic-search-section .track-result-card {
display: flex !important;
align-items: center !important;
gap: 16px !important;
padding: 16px 22px !important;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(0, 0, 0, 0.18) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
border-radius: 14px !important;
position: relative;
transition: border-color 0.2s, transform 0.2s, background 0.2s !important;
margin: 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 2px 10px rgba(0, 0, 0, 0.2) !important;
transform: none !important;
}
#basic-search-section .track-result-card::before {
content: '';
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 2px;
background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.7),
rgba(var(--accent-rgb), 0.3));
border-radius: 0 2px 2px 0;
z-index: 2;
}
#basic-search-section .track-result-card:hover {
border-color: rgba(var(--accent-rgb), 0.3) !important;
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.06) 0%,
rgba(0, 0, 0, 0.18) 100%) !important;
transform: translateY(-1px) !important;
}
#basic-search-section .track-icon {
width: 44px !important;
height: 44px !important;
min-width: 44px;
border-radius: 10px !important;
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 22px !important;
flex-shrink: 0;
}
#basic-search-section .track-info {
flex: 1 1 auto !important;
min-width: 0;
}
#basic-search-section .track-title {
color: rgba(255, 255, 255, 0.95) !important;
font-size: 14px !important;
font-weight: 600 !important;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 3px 0 !important;
}
#basic-search-section .track-artist {
color: rgba(255, 255, 255, 0.6) !important;
font-size: 12px !important;
line-height: 1.3;
margin: 0 0 5px 0 !important;
}
#basic-search-section .track-details {
color: rgba(255, 255, 255, 0.5) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 0 3px 0 !important;
}
#basic-search-section .track-uploader {
color: rgba(255, 255, 255, 0.38) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 !important;
}
#basic-search-section .track-actions {
display: flex !important;
gap: 6px !important;
flex-shrink: 0;
}
/* — Action buttons inside album / track cards: pill primary + ghost. */
#basic-search-section .album-download-btn,
#basic-search-section .album-matched-btn,
#basic-search-section .track-download-btn,
#basic-search-section .track-matched-btn,
#basic-search-section .track-stream-btn {
padding: 6px 12px;
border-radius: 99px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
#basic-search-section .album-download-btn,
#basic-search-section .track-download-btn {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 92%, white 8%),
rgb(var(--accent-rgb)));
border: 1px solid rgba(var(--accent-rgb), 0.5);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 22%, transparent);
}
#basic-search-section .album-download-btn:hover,
#basic-search-section .track-download-btn:hover {
transform: translateY(-1px);
box-shadow: 0 5px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent);
}
#basic-search-section .album-matched-btn,
#basic-search-section .track-matched-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(var(--accent-rgb), 0.3);
color: rgb(var(--accent-light-rgb));
}
#basic-search-section .album-matched-btn:hover,
#basic-search-section .track-matched-btn:hover {
background: color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
border-color: rgba(var(--accent-rgb), 0.5);
color: #fff;
}
#basic-search-section .track-stream-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.65);
}
#basic-search-section .track-stream-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
color: #fff;
}
/* — Expanded album track list */
#basic-search-section .album-track-list {
background: rgba(0, 0, 0, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.04);
padding: 4px 0;
}
#basic-search-section .track-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px 10px 50px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.15s;
}
#basic-search-section .track-item:last-child {
border-bottom: none;
}
#basic-search-section .track-item:hover {
background: rgba(255, 255, 255, 0.02);
}
#basic-search-section .track-item-info {
flex: 1;
min-width: 0;
}
#basic-search-section .track-item-title {
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#basic-search-section .track-item-details {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
margin-top: 2px;
}
#basic-search-section .track-item-actions {
display: flex;
gap: 5px;
flex-shrink: 0;
}
#basic-search-section .track-item-actions .track-stream-btn,
#basic-search-section .track-item-actions .track-download-btn,
#basic-search-section .track-item-actions .track-matched-btn {
padding: 4px 10px;
font-size: 9px;
}
#basic-search-section .disc-separator {
padding: 8px 18px 6px 50px !important;
font-weight: 700 !important;
font-size: 10px !important;
color: rgb(var(--accent-light-rgb)) !important;
border-bottom: 1px solid rgba(var(--accent-rgb), 0.2) !important;
letter-spacing: 0.12em;
text-transform: uppercase;
background: none !important;
margin: 0 !important;
}
/* — Responsive: collapse action button row on narrow viewports. */
@media (max-width: 900px) {
#basic-search-section .album-actions,
#basic-search-section .track-actions {
flex-direction: column;
}
.bs-filters {
gap: 10px 16px;
}
}

View file

@ -4772,7 +4772,69 @@ function updateModalSyncProgress(playlistId, progress) {
} }
// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). // ── Basic-search source picker ──────────────────────────────────────────────
// Tracks which download source the user has selected in the chip row. Null
// means "use the orchestrator's default" (same as pre-redesign behaviour).
let _bsActiveSource = null;
async function initBasicSearchSources() {
const row = document.getElementById('bs-source-row');
if (!row) return;
try {
const resp = await fetch('/api/search/sources');
if (!resp.ok) return;
const { mode, sources } = await resp.json();
if (!sources || !sources.length) return;
row.innerHTML = '';
const isSingle = mode !== 'hybrid' || sources.length < 2;
sources.forEach((src, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bs-source-chip' + (i === 0 ? ' active' : '');
btn.dataset.source = src.name;
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
btn.setAttribute('title', src.display_name);
btn.innerHTML = `<span class="bs-source-label">${_escBsHtml(src.display_name)}</span>`;
if (isSingle) {
// Non-interactive: single source, just a label.
btn.classList.add('single');
btn.disabled = true;
} else {
btn.addEventListener('click', () => {
row.querySelectorAll('.bs-source-chip').forEach(c => {
c.classList.remove('active');
c.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
_bsActiveSource = src.name;
// Re-run last search with new source if results already showing.
const query = document.getElementById('downloads-search-input')?.value?.trim();
if (query && window.currentSearchResults?.length) {
performDownloadsSearch();
}
});
}
row.appendChild(btn);
});
// Default active source = first in chain.
_bsActiveSource = isSingle ? null : sources[0]?.name ?? null;
} catch (_err) {
// Non-fatal — search still works without the picker.
}
}
function _escBsHtml(str) {
return String(str || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Raw download-source file search (basic search tab).
async function performDownloadsSearch() { async function performDownloadsSearch() {
const query = document.getElementById('downloads-search-input').value.trim(); const query = document.getElementById('downloads-search-input').value.trim();
if (!query) { if (!query) {
@ -4802,10 +4864,15 @@ async function performDownloadsSearch() {
displayDownloadsResults([]); // Clear previous results displayDownloadsResults([]); // Clear previous results
// --- 2. Perform the Fetch Request --- // --- 2. Perform the Fetch Request ---
// Source param routes the search to a specific download source in
// hybrid mode. Omitted in single-source mode (backend falls through
// to orchestrator.search() which targets the configured source).
const body = { query };
if (_bsActiveSource) body.source = _bsActiveSource;
const response = await fetch('/api/search', { const response = await fetch('/api/search', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }), body: JSON.stringify(body),
signal: searchAbortController.signal // Link fetch to the AbortController signal: searchAbortController.signal // Link fetch to the AbortController
}); });
@ -4825,7 +4892,8 @@ async function performDownloadsSearch() {
statusText.textContent = `No results found for '${query}'`; statusText.textContent = `No results found for '${query}'`;
showToast('No results found', 'error'); showToast('No results found', 'error');
} else { } else {
document.getElementById('filters-container').classList.remove('hidden'); const filtersEl = document.getElementById('filters-container');
if (filtersEl) filtersEl.classList.remove('hidden');
// Count albums and singles like the GUI app // Count albums and singles like the GUI app
let totalAlbums = 0; let totalAlbums = 0;
@ -5658,6 +5726,15 @@ let _gsController = null;
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); } else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
})(); })();
// Init basic-search source chip row once the DOM is ready.
(function _bsInit() {
const run = () => {
if (typeof initBasicSearchSources === 'function') initBasicSearchSources();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
else run();
})();
function _gsUpdateVisibility() { function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar'); const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura'); const aura = document.getElementById('gsearch-aura');

View file

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = { const WHATS_NEW = {
'2.6.5': [ '2.6.5': [
{ unreleased: true }, { unreleased: true },
{ title: 'Basic search: visual overhaul + per-source picker in hybrid mode', desc: 'the basic search tab on the Search page was carrying its original visual treatment from before the rest of the app moved toward the glassy / accent-radial aesthetic, and it always targeted the first source in the hybrid chain with no way to pick a different one. Two things in this commit: (1) full visual redesign — glass search-bar card with accent radial wash + focus ring, pill primary search button, always-visible compact filter pill row (Type / Format / Sort), accent-tinted status pill, album result cards with accent left-edge stripe + cover icon + chevron expand + pill action buttons, track result cards as slim glass rows, multi-disc separators in album track lists, responsive button-stack on narrow viewports. Self-contained sheet at ``webui/static/basic-search-v2.css`` so revert is just dropping the link tag. (2) source picker — small chip row above the search bar lists every active source in the hybrid chain. Click a chip to target that specific source for the next search. In single-source mode the chip is rendered as a dashed-border label so the user always knows what they\'re searching. New ``GET /api/search/sources`` endpoint returns the active source list; ``/api/search`` accepts an optional ``source`` body param to target that specific source via its client. Backwards compatible — omitting ``source`` uses the orchestrator default exactly as before. Pinned with 5 new unit tests (source routing, fallback on unknown source, no-source default, alias preservation, album serialisation through the source-targeted path) on top of the existing 6 tests.', page: 'search' },
{ title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' }, { title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' },
], ],
'2.6.4': [ '2.6.4': [

View file

@ -7155,7 +7155,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
align-items: center; align-items: center;
gap: 18px; gap: 18px;
position: relative; position: relative;
overflow: hidden;
/* Neumorphic depth shadows - elevated card effect */ /* Neumorphic depth shadows - elevated card effect */
box-shadow: box-shadow:
@ -7255,7 +7254,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin: 12px 8px; margin: 12px 8px;
padding: 24px; padding: 24px;
position: relative; position: relative;
overflow: hidden;
/* Neumorphic depth shadows - elevated card effect */ /* Neumorphic depth shadows - elevated card effect */
box-shadow: box-shadow: