diff --git a/CHANGELOG.md b/CHANGELOG.md index aab4293..a239dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Commander hover preview collapses to a card-only view when browsing commanders, and all theme chips display without the previous “+ more” overflow badge. - Added a Content Security Policy upgrade directive so proxied HTTPS deployments safely rewrite commander pagination requests to HTTPS, preventing mixed-content blocks. - Commander thumbnails use a fixed-width 160px frame (scaling down on small screens) to eliminate inconsistent image sizing across the catalog. +- Commander browser search now separates commander name and theme inputs, introduces fuzzy theme suggestions, and tightens commander name matching to near-exact results. +- Commander browser no longer auto-scrolls when typing in search fields, keeping focus anchored near the filters. +- Commander theme chips feature larger typography, multi-line wrapping, and a mobile-friendly tap dialog for reading summaries. +- Theme dialog now prefers full editorial descriptions, so longer summaries display completely on mobile. +- Commander theme labels now unescape leading punctuation (e.g., +2/+2 Counters) to avoid stray backslashes in the UI. +- Theme summary dialog now opens when clicking theme chips on desktop as well as mobile. - Commander list pagination controls now appear above and below the results and automatically scroll to the top when switching pages for quicker navigation. - Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability. - Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 2c1b269..be1f99e 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -18,6 +18,12 @@ - Commander hover preview shows card-only panel in browser context and removes the “+ more” overflow badge from theme chips. - Content Security Policy upgrade directive ensures HTMX pagination requests remain HTTPS-safe behind proxies. - Commander thumbnails adopt a fixed-width 160px frame (responsive on small screens) for consistent layout. +- Commander browser now separates name vs theme search, adds fuzzy theme suggestions, and tightens commander name matching to near-exact results. +- Commander search results stay put while filtering; typing no longer auto-scrolls the page away from the filter controls. +- Commander theme chips are larger, wrap cleanly, and display an accessible summary dialog when tapped on mobile. +- Theme dialogs now surface the full editorial description when available, improving longer summaries on small screens. +- Commander theme names unescape leading punctuation (e.g., +2/+2 Counters) so labels render without stray backslashes. +- Theme summary dialog also opens on desktop clicks, giving parity with mobile behavior. - Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability. - Preview performance CI check now waits for service health and retries catalog pagination fetches to smooth out transient 500s on cold boots. diff --git a/code/tests/test_commander_catalog_loader.py b/code/tests/test_commander_catalog_loader.py index 7ae3a0f..cdc958c 100644 --- a/code/tests/test_commander_catalog_loader.py +++ b/code/tests/test_commander_catalog_loader.py @@ -1,5 +1,7 @@ from __future__ import annotations +import csv +import json import time from pathlib import Path @@ -69,3 +71,61 @@ def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytes updated = loader.load_commander_catalog() assert updated is not first assert "zada-hedron-grinder" in updated.by_slug + + +def test_commander_theme_labels_unescape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + custom_dir = tmp_path / "csv_custom" + custom_dir.mkdir() + csv_path = custom_dir / "commander_cards.csv" + with csv_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "name", + "faceName", + "edhrecRank", + "colorIdentity", + "colors", + "manaCost", + "manaValue", + "type", + "creatureTypes", + "text", + "power", + "toughness", + "keywords", + "themeTags", + "layout", + "side", + ] + ) + theme_value = json.dumps([r"\+2/\+2 Counters", "+1/+1 Counters"]) + writer.writerow( + [ + "Escape Tester", + "Escape Tester", + "1234", + "R", + "R", + "{3}{R}", + "4", + "Legendary Creature — Archer", + "['Archer']", + "Test", + "2", + "2", + "", + theme_value, + "normal", + "", + ] + ) + + _set_csv_dir(monkeypatch, custom_dir) + + catalog = loader.load_commander_catalog() + assert len(catalog.entries) == 1 + + record = catalog.entries[0] + assert record.themes == ("+2/+2 Counters", "+1/+1 Counters") + assert "+2/+2 counters" in record.theme_tokens diff --git a/code/tests/test_commanders_route.py b/code/tests/test_commanders_route.py index 14a93f0..6f4d064 100644 --- a/code/tests/test_commanders_route.py +++ b/code/tests/test_commanders_route.py @@ -76,7 +76,15 @@ def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> N search_haystack=f"{name.lower()}" ) records.append(record) - fake_catalog = SimpleNamespace(entries=tuple(records)) + _install_custom_catalog(monkeypatch, records) + + +def _install_custom_catalog(monkeypatch: pytest.MonkeyPatch, records: list) -> None: + fake_catalog = SimpleNamespace( + entries=tuple(records), + by_slug={record.slug: record for record in records}, + ) + def loader() -> SimpleNamespace: return fake_catalog @@ -120,13 +128,7 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp themes=themes, theme_tokens=tuple(theme.lower() for theme in themes), ) - fake_catalog = SimpleNamespace(entries=(enriched,)) - - def loader() -> SimpleNamespace: - return fake_catalog - - monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader) - monkeypatch.setattr(commanders, "load_commander_catalog", loader) + _install_custom_catalog(monkeypatch, [enriched]) response = client.get("/commanders") assert response.status_code == 200 @@ -135,3 +137,155 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp assert "commander-theme-chip-more" not in body # no overflow badge rendered for name in themes: assert name in body + + +def _commander_fixture(sample, *, name: str, slug: str, themes: tuple[str, ...] = ()): + return replace( + sample, + name=name, + face_name=name, + display_name=name, + slug=slug, + themes=themes, + theme_tokens=tuple(theme.lower() for theme in themes), + search_haystack="|".join([name.lower(), *[theme.lower() for theme in themes]]), + ) + + +def test_commanders_search_ignores_theme_tokens(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + target = _commander_fixture( + sample, + name="Avatar Aang // Aang, Master of Elements", + slug="avatar-aang", + themes=("Elemental", "Avatar"), + ) + other = _commander_fixture( + sample, + name="Generic Guardian", + slug="generic-guardian", + themes=("Avatar", "Guardian"), + ) + _install_custom_catalog(monkeypatch, [target, other]) + + response = client.get("/commanders", params={"q": "Avatar Aang"}) + assert response.status_code == 200 + body = response.text + + assert 'data-commander-slug="avatar-aang"' in body + assert 'data-commander-slug="generic-guardian"' not in body + + +def test_commanders_search_supports_token_reordering(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + target = _commander_fixture( + sample, + name="Avatar Aang // Aang, Master of Elements", + slug="avatar-aang", + themes=("Elemental",), + ) + fallback = _commander_fixture( + sample, + name="Master of Avatar Arts", + slug="master-of-avatar-arts", + themes=("Avatar",), + ) + _install_custom_catalog(monkeypatch, [target, fallback]) + + response = client.get("/commanders", params={"q": "Aang Avatar"}) + assert response.status_code == 200 + body = response.text + + assert 'data-commander-slug="avatar-aang"' in body + assert 'data-commander-slug="master-of-avatar-arts"' not in body + + +def test_commanders_theme_search_filters(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + aggro_commander = _commander_fixture( + sample, + name="Aggro Ace", + slug="aggro-ace", + themes=("Aggro", "Combat"), + ) + control_commander = _commander_fixture( + sample, + name="Control Keeper", + slug="control-keeper", + themes=("Control", "Value"), + ) + _install_custom_catalog(monkeypatch, [aggro_commander, control_commander]) + + response = client.get("/commanders", params={"theme": "Aggo"}) + assert response.status_code == 200 + body = response.text + + assert 'data-commander-slug="aggro-ace"' in body + assert 'data-commander-slug="control-keeper"' not in body + assert 'data-theme-suggestion="Aggro"' in body + assert 'id="theme-suggestions"' in body + assert 'option value="Aggro"' in body + + +def test_commanders_theme_recommendations_render_in_fragment(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + aggro_commander = _commander_fixture( + sample, + name="Aggro Ace", + slug="aggro-ace", + themes=("Aggro", "Combat"), + ) + control_commander = _commander_fixture( + sample, + name="Control Keeper", + slug="control-keeper", + themes=("Control", "Value"), + ) + _install_custom_catalog(monkeypatch, [aggro_commander, control_commander]) + + response = client.get( + "/commanders", + params={"theme": "Aggo"}, + headers={"HX-Request": "true"}, + ) + assert response.status_code == 200 + body = response.text + + assert 'data-theme-suggestion="Aggro"' in body + assert 'data-commander-slug="aggro-ace"' in body + + +def test_commander_name_fuzzy_tightened(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + catalog = load_commander_catalog() + sample = catalog.entries[0] + finneas = _commander_fixture( + sample, + name="Finneas, Ace Archer", + slug="finneas-ace-archer", + themes=("Aggro", "Counters"), + ) + torgal = _commander_fixture( + sample, + name="Torgal, A Fine Hound", + slug="torgal-a-fine-hound", + themes=("Aggro", "Combat"), + ) + gorbag = _commander_fixture( + sample, + name="Gorbag of Minas Morgul", + slug="gorbag-of-minas-morgul", + themes=("Aggro", "Treasure"), + ) + _install_custom_catalog(monkeypatch, [finneas, torgal, gorbag]) + + response = client.get("/commanders", params={"q": "Finneas"}) + assert response.status_code == 200 + body = response.text + + assert 'data-commander-slug="finneas-ace-archer"' in body + assert 'data-commander-slug="torgal-a-fine-hound"' not in body + assert 'data-commander-slug="gorbag-of-minas-morgul"' not in body diff --git a/code/web/app.py b/code/web/app.py index 2335fe4..4aa028b 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -108,9 +108,9 @@ SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False) ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True) ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False) ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) -ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False) -RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy) -RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False) +ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) +RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy) +RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True) THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False) def _as_int(val: str | None, default: int) -> int: try: diff --git a/code/web/routes/commanders.py b/code/web/routes/commanders.py index eb298a7..84c45bb 100644 --- a/code/web/routes/commanders.py +++ b/code/web/routes/commanders.py @@ -1,9 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +from difflib import SequenceMatcher from math import ceil from typing import Iterable, Mapping, Sequence from urllib.parse import urlencode +import re from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse @@ -16,6 +18,11 @@ from ..services.telemetry import log_commander_page_view router = APIRouter(prefix="/commanders", tags=["commanders"]) PAGE_SIZE = 20 +_THEME_MATCH_THRESHOLD = 0.52 +_THEME_RECOMMENDATION_FLOOR = 0.35 +_THEME_RECOMMENDATION_LIMIT = 6 +_MIN_NAME_MATCH_SCORE = 0.8 +_WORD_PATTERN = re.compile(r"[a-z0-9]+") _WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G") _COLOR_NAMES: dict[str, str] = { @@ -76,6 +83,12 @@ class CommanderView: partner_summary: tuple[str, ...] +@dataclass(frozen=True, slots=True) +class ThemeRecommendation: + name: str + score: float + + def _is_htmx(request: Request) -> bool: return request.headers.get("HX-Request", "").lower() == "true" @@ -169,22 +182,203 @@ def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderT ) -def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None) -> list[CommanderRecord]: +def _normalize_search_text(value: str | None) -> str: + if not value: + return "" + tokens = _WORD_PATTERN.findall(value.lower()) + if not tokens: + return "" + return " ".join(tokens) + + +def _commander_name_candidates(record: CommanderRecord) -> tuple[str, ...]: + seen: set[str] = set() + candidates: list[str] = [] + for raw in (record.display_name, record.face_name, record.name): + normalized = _normalize_search_text(raw) + if not normalized: + continue + if normalized not in seen: + seen.add(normalized) + candidates.append(normalized) + return tuple(candidates) + + +def _partial_ratio(a: str, b: str) -> float: + if not a or not b: + return 0.0 + shorter, longer = (a, b) if len(a) <= len(b) else (b, a) + length = len(shorter) + if length == 0: + return 0.0 + best = 0.0 + window_count = len(longer) - length + 1 + for start in range(max(1, window_count)): + end = start + length + if end > len(longer): + segment = longer[-length:] + else: + segment = longer[start:end] + score = SequenceMatcher(None, shorter, segment).ratio() + if score > best: + best = score + if best >= 0.99: + break + return best + + +def _token_scores(query_tokens: tuple[str, ...], candidate_tokens: tuple[str, ...]) -> tuple[float, float]: + if not query_tokens or not candidate_tokens: + return 0.0, 0.0 + totals: list[float] = [] + for token in query_tokens: + best = 0.0 + for candidate in candidate_tokens: + score = SequenceMatcher(None, token, candidate).ratio() + if score > best: + best = score + if best >= 0.99: + break + totals.append(best) + average = sum(totals) / len(totals) if totals else 0.0 + minimum = min(totals) if totals else 0.0 + return average, minimum + + +def _commander_name_match_score(query: str, record: CommanderRecord) -> float: + normalized_query = _normalize_search_text(query) + if not normalized_query: + return 0.0 + query_tokens = tuple(normalized_query.split()) + best_score = 0.0 + for candidate in _commander_name_candidates(record): + candidate_tokens = tuple(candidate.split()) + base_score = SequenceMatcher(None, normalized_query, candidate).ratio() + partial = _partial_ratio(normalized_query, candidate) + token_average, token_minimum = _token_scores(query_tokens, candidate_tokens) + + substring_bonus = 0.0 + if candidate.startswith(normalized_query): + substring_bonus = 1.0 + elif query_tokens and all(token in candidate_tokens for token in query_tokens): + substring_bonus = 0.92 + elif normalized_query in candidate: + substring_bonus = 0.88 + elif query_tokens and all(token in candidate for token in query_tokens): + substring_bonus = 0.8 + elif query_tokens and any(token in candidate for token in query_tokens): + substring_bonus = 0.65 + + score = max(base_score, partial, token_average, substring_bonus) + if query_tokens and token_minimum < 0.45 and not candidate.startswith(normalized_query) and normalized_query not in candidate: + score = min(score, token_minimum) + if score > best_score: + best_score = score + if best_score >= 0.999: + break + return best_score + + +def _collect_theme_names(records: Sequence[CommanderRecord]) -> tuple[str, ...]: + seen: set[str] = set() + ordered: list[str] = [] + for rec in records: + for theme_name in rec.themes: + if not theme_name: + continue + if theme_name not in seen: + seen.add(theme_name) + ordered.append(theme_name) + ordered.sort(key=lambda name: name.lower()) + return tuple(ordered) + + +def _theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], candidate: str) -> float: + normalized_candidate = _normalize_search_text(candidate) + if not normalized_candidate: + return 0.0 + candidate_tokens = tuple(normalized_candidate.split()) + base_score = SequenceMatcher(None, normalized_query, normalized_candidate).ratio() + partial = _partial_ratio(normalized_query, normalized_candidate) + token_average, token_minimum = _token_scores(query_tokens, candidate_tokens) + + substring_bonus = 0.0 + if normalized_candidate.startswith(normalized_query): + substring_bonus = 1.0 + elif normalized_query in normalized_candidate: + substring_bonus = 0.9 + elif query_tokens and all(token in candidate_tokens for token in query_tokens): + substring_bonus = 0.85 + elif query_tokens and any(token in candidate_tokens for token in query_tokens): + substring_bonus = 0.7 + + score = max(base_score, partial, token_average, substring_bonus) + if query_tokens and token_minimum < 0.4 and not normalized_candidate.startswith(normalized_query) and normalized_query not in normalized_candidate: + score = min(score, max(token_minimum, 0.0)) + return score + + +def _best_theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], record: CommanderRecord) -> float: + best = 0.0 + for theme_name in record.themes: + score = _theme_match_score(normalized_query, query_tokens, theme_name) + if score > best: + best = score + if best >= 0.999: + break + return best + + +def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[str]) -> tuple[ThemeRecommendation, ...]: + normalized_query = _normalize_search_text(theme_query) + if not normalized_query: + return tuple() + query_tokens = tuple(normalized_query.split()) + scored: list[ThemeRecommendation] = [] + for name in theme_names: + score = _theme_match_score(normalized_query, query_tokens, name) + if score <= 0.0: + continue + scored.append(ThemeRecommendation(name=name, score=score)) + if not scored: + return tuple() + scored.sort(key=lambda item: (-item.score, item.name.lower())) + filtered = [item for item in scored if item.score >= _THEME_RECOMMENDATION_FLOOR] + if not filtered: + filtered = scored + return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT]) + + +def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]: items = list(records) color_code = _canon_color_code(color) if color_code: items = [rec for rec in items if _record_color_code(rec) == color_code] - if q: - lowered = q.lower().strip() - if lowered: - tokens = [tok for tok in lowered.split() if tok] - if tokens: - filtered: list[CommanderRecord] = [] - for rec in items: - haystack = rec.search_haystack or "" - if all(tok in haystack for tok in tokens): - filtered.append(rec) - items = filtered + normalized_query = _normalize_search_text(q) + if normalized_query: + filtered: list[tuple[float, CommanderRecord]] = [] + for rec in items: + score = _commander_name_match_score(normalized_query, rec) + if score >= _MIN_NAME_MATCH_SCORE: + filtered.append((score, rec)) + if filtered: + filtered.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower())) + items = [rec for _, rec in filtered] + else: + items = [] + normalized_theme_query = _normalize_search_text(theme) + if normalized_theme_query and items: + theme_tokens = tuple(normalized_theme_query.split()) + filtered_by_theme: list[tuple[float, CommanderRecord]] = [] + for rec in items: + score = _best_theme_match_score(normalized_theme_query, theme_tokens, rec) + if score >= _THEME_MATCH_THRESHOLD: + filtered_by_theme.append((score, rec)) + if filtered_by_theme: + filtered_by_theme.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower())) + items = [rec for _, rec in filtered_by_theme] + else: + items = [] return items @@ -226,7 +420,11 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander try: data = idx.summary_by_slug.get(slug) if data: - summary = data.get("short_description") or data.get("description") + description = data.get("description") if isinstance(data, dict) else None + short_description = data.get("short_description") if isinstance(data, dict) else None + summary = description or short_description + if (summary is None or not summary.strip()) and short_description: + summary = short_description except Exception: summary = None info[name] = CommanderTheme(name=name, slug=slug, summary=summary) @@ -237,6 +435,7 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander async def commanders_index( request: Request, q: str | None = Query(default=None, alias="q"), + theme: str | None = Query(default=None, alias="theme"), color: str | None = Query(default=None, alias="color"), page: int = Query(default=1, ge=1), ) -> HTMLResponse: @@ -247,7 +446,10 @@ async def commanders_index( entries = catalog.entries except FileNotFoundError: error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists." - filtered = _filter_commanders(entries, q, color) + theme_names = _collect_theme_names(entries) + theme_query = (theme or "").strip() + filtered = _filter_commanders(entries, q, color, theme_query) + theme_recommendations = _build_theme_recommendations(theme_query, theme_names) total_filtered = len(filtered) page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1 if page > page_count: @@ -268,6 +470,8 @@ async def commanders_index( params: dict[str, str] = {} if q: params["q"] = q + if theme_query: + params["theme"] = theme_query if canon_color: params["color"] = canon_color params["page"] = str(page_value) @@ -289,8 +493,11 @@ async def commanders_index( "request": request, "commanders": views, "query": q or "", + "theme_query": theme_query, "color": canon_color, "color_options": color_options, + "theme_options": theme_names, + "theme_recommendations": theme_recommendations, "total_count": len(entries), "result_count": len(views), "result_total": total_filtered, @@ -305,7 +512,7 @@ async def commanders_index( "next_page": next_page, "prev_url": prev_url, "next_url": next_url, - "is_filtered": bool((q or "").strip() or (color or "").strip()), + "is_filtered": bool((q or "").strip() or (color or "").strip() or theme_query), "error": error, "return_url": return_url, } diff --git a/code/web/services/commander_catalog_loader.py b/code/web/services/commander_catalog_loader.py index a05d78d..e416cee 100644 --- a/code/web/services/commander_catalog_loader.py +++ b/code/web/services/commander_catalog_loader.py @@ -49,6 +49,7 @@ _COLOR_ALIAS = { } _WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G") _SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image" +_THEME_ESCAPE_PATTERN = re.compile(r"\\([+/\\-])") @dataclass(frozen=True, slots=True) @@ -214,7 +215,8 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm power = _clean_str(row.get("power")) or None toughness = _clean_str(row.get("toughness")) or None keywords = tuple(_split_to_list(row.get("keywords"))) - themes = tuple(_parse_literal_list(row.get("themeTags"))) + raw_themes = _parse_literal_list(row.get("themeTags")) + themes = tuple(filter(None, (_clean_theme_label(theme) for theme in raw_themes))) theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t)) edhrec_rank = _parse_int(row.get("edhrecRank")) layout = _clean_str(row.get("layout")) or "normal" @@ -317,6 +319,14 @@ def _parse_literal_list(value: object) -> List[str]: return [part for part in parts if part] +def _clean_theme_label(value: str) -> str: + text = _clean_str(value) + if not text: + return "" + text = text.replace("\ufeff", "") + return _THEME_ESCAPE_PATTERN.sub(r"\1", text) + + def _split_to_list(value: object) -> List[str]: text = _clean_str(value) if not text: diff --git a/code/web/templates/commanders/index.html b/code/web/templates/commanders/index.html index 5e62626..256a5f5 100644 --- a/code/web/templates/commanders/index.html +++ b/code/web/templates/commanders/index.html @@ -13,16 +13,25 @@ method="get" hx-get="/commanders" hx-target="#commander-results" - hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search" + hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search, keyup changed delay:300ms from:#commander-theme" hx-include="#commander-filter-form" hx-push-url="true" hx-indicator="#commander-loading" novalidate > + + + {% for name in theme_options[:200] %} + + {% endfor %} +