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 > - Search - + Commander name + + + Theme + + + + {% for name in theme_options[:200] %} + + {% endfor %} + Color identity @@ -89,9 +98,9 @@ .commander-name { margin:0; font-size:1.25rem; } .color-identity { display:flex; align-items:center; gap:.35rem; } .commander-context { margin:0; font-size:.95rem; } - .commander-themes { display:flex; flex-wrap:wrap; gap:.4rem; } + .commander-themes { display:flex; flex-wrap:wrap; gap:.45rem; width:100%; } .commander-themes-empty { font-size:.85rem; } - .commander-theme-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; appearance:none; font:inherit; } + .commander-theme-chip { display:inline-flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:.2rem; padding:4px 12px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.85rem; line-height:1.3; font-weight:600; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .18s ease, border-color .18s ease, transform .18s ease; appearance:none; font-family:inherit; white-space:normal; text-align:center; min-width:0; max-width:min(100%, 24ch); word-break:break-word; overflow-wrap: anywhere; } .commander-theme-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; } .commander-theme-chip:hover { background:rgba(148,163,184,.25); border-color:rgba(148,163,184,.45); transform:translateY(-1px); } .commander-partners { display:flex; flex-wrap:wrap; gap:.4rem; font-size:.85rem; } @@ -105,6 +114,12 @@ .commander-pagination .commander-page-btn[disabled], .commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; } .commander-pagination-status { font-size:.85rem; color:var(--muted); } + .theme-recommendations { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem 1rem; margin-top:.75rem; } + .theme-recommendations-label { font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); } + .theme-recommendations-chips { display:flex; flex-wrap:wrap; gap:.4rem; } + .theme-suggestion-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(94,106,136,.18); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; } + .theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); } + .theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; } .commander-loading { display:none; margin-top:1rem; } .commander-loading.htmx-request { display:block; } @@ -124,6 +139,17 @@ 0% { background-position:100% 0; } 100% { background-position:-100% 0; } } + .commander-theme-dialog { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:1.5rem; background:rgba(15,23,42,.55); backdrop-filter:blur(6px); z-index:1300; } + .commander-theme-dialog[data-open="true"] { display:flex; } + .commander-theme-dialog__panel { background:var(--panel); color:var(--text); border-radius:16px; width:min(92vw, 420px); border:1px solid rgba(148,163,184,.35); box-shadow:0 24px 60px rgba(15,23,42,.42); padding:1.5rem; display:flex; flex-direction:column; gap:1rem; } + .commander-theme-dialog__title { margin:0; font-size:1.45rem; line-height:1.2; } + .commander-theme-dialog__body { margin:0; font-size:1.2rem; line-height:1.65; white-space:normal; word-break:break-word; } + .commander-theme-dialog__close { align-self:flex-end; min-width:0; } + @media (max-width: 640px) { + .commander-theme-dialog__panel { width:min(94vw, 360px); padding:1.25rem; } + .commander-theme-dialog__title { font-size:1.3rem; } + .commander-theme-dialog__body { font-size:1.1rem; } + } @media (max-width: 900px) { .commander-row { flex-direction:column; } @@ -148,10 +174,106 @@ if (!pageInput) return; const resetPage = () => { pageInput.value = '1'; }; + const setLastTrigger = (value) => { form.dataset.lastTrigger = value; }; const searchField = document.getElementById('commander-search'); const colorField = document.getElementById('commander-color'); - if (searchField) searchField.addEventListener('input', resetPage); - if (colorField) colorField.addEventListener('change', resetPage); + const themeField = document.getElementById('commander-theme'); + if (searchField) { + searchField.addEventListener('input', () => { + resetPage(); + setLastTrigger('search'); + }); + } + if (colorField) { + colorField.addEventListener('change', () => { + resetPage(); + setLastTrigger('color'); + }); + } + if (themeField) { + themeField.addEventListener('input', () => { + resetPage(); + setLastTrigger('theme'); + }); + } + form.addEventListener('submit', () => { + if (!form.dataset.lastTrigger) { + setLastTrigger('submit'); + } + }); + + const coarseQuery = window.matchMedia('(pointer: coarse)'); + const prefersThemeModal = () => (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; + + let themeDialog; + let themeDialogTitle; + let themeDialogBody; + let themeDialogClose; + + function closeThemeDialog() { + if (!themeDialog || themeDialog.dataset.open !== 'true') return; + themeDialog.dataset.open = 'false'; + themeDialog.setAttribute('aria-hidden', 'true'); + const invoker = themeDialog.__lastInvoker; + themeDialog.__lastInvoker = null; + if (invoker && typeof invoker.focus === 'function') { + try { + invoker.focus({ preventScroll: true }); + } catch (_) {} + } + } + + const ensureThemeDialog = () => { + if (themeDialog) return themeDialog; + themeDialog = document.getElementById('commander-theme-dialog'); + if (!themeDialog) { + themeDialog = document.createElement('div'); + themeDialog.id = 'commander-theme-dialog'; + themeDialog.className = 'commander-theme-dialog'; + themeDialog.setAttribute('aria-hidden', 'true'); + themeDialog.innerHTML = ` + + + + Close + + `; + document.body.appendChild(themeDialog); + } + themeDialogTitle = themeDialog.querySelector('.commander-theme-dialog__title'); + themeDialogBody = themeDialog.querySelector('.commander-theme-dialog__body'); + themeDialogClose = themeDialog.querySelector('.commander-theme-dialog__close'); + if (themeDialogClose && !themeDialogClose.__bound) { + themeDialogClose.__bound = true; + themeDialogClose.addEventListener('click', () => closeThemeDialog()); + } + if (!themeDialog.__backdropBound) { + themeDialog.__backdropBound = true; + themeDialog.addEventListener('click', (evt) => { + if (evt.target === themeDialog) { + closeThemeDialog(); + } + }); + } + return themeDialog; + }; + + const openThemeDialog = (name, summary, invoker) => { + ensureThemeDialog(); + if (!themeDialog) return; + themeDialog.setAttribute('aria-hidden', 'false'); + themeDialog.dataset.open = 'true'; + themeDialog.__lastInvoker = invoker || null; + if (themeDialogTitle) themeDialogTitle.textContent = name || 'Theme'; + if (themeDialogBody) themeDialogBody.textContent = summary && summary.trim() ? summary : 'Summary unavailable.'; + requestAnimationFrame(() => { + if (themeDialogClose) { + try { + themeDialogClose.focus({ preventScroll: true }); + } catch (_) {} + } + }); + }; const updatePageFromResults = (container) => { if (!container) return; @@ -170,6 +292,11 @@ const container = document.getElementById('commander-results'); const searchEl = document.getElementById('commander-search'); if (!container) return; + const lastTrigger = form.dataset.lastTrigger || ''; + form.dataset.lastTrigger = ''; + if (lastTrigger === 'search' || lastTrigger === 'theme') { + return; + } const invoker = event.detail && event.detail.elt ? event.detail.elt : null; const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]'); // If not from bottom, check whether the top of the results is already within view; if so, skip scroll @@ -196,6 +323,39 @@ }); updatePageFromResults(document.getElementById('commander-results')); + + document.body.addEventListener('click', (event) => { + const suggestion = event.target && event.target.closest ? event.target.closest('[data-theme-suggestion]') : null; + if (suggestion) { + if (!themeField) return; + event.preventDefault(); + const value = suggestion.getAttribute('data-theme-suggestion') || ''; + themeField.value = value; + resetPage(); + setLastTrigger('theme'); + themeField.dispatchEvent(new Event('input', { bubbles: true })); + try { + form.requestSubmit(); + } catch (_) { + form.submit(); + } + return; + } + const chip = event.target && event.target.closest ? event.target.closest('.commander-theme-chip') : null; + if (chip) { + event.preventDefault(); + const name = chip.getAttribute('data-theme-name') || chip.textContent.trim(); + const summary = chip.getAttribute('data-theme-summary') || 'Summary unavailable.'; + openThemeDialog(name, summary, chip); + return; + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeThemeDialog(); + } + }); })(); {% endblock %} diff --git a/code/web/templates/commanders/list_fragment.html b/code/web/templates/commanders/list_fragment.html index b84b1f4..2ec8bd5 100644 --- a/code/web/templates/commanders/list_fragment.html +++ b/code/web/templates/commanders/list_fragment.html @@ -13,6 +13,16 @@ No commander data available. {% endif %} + {% if theme_query and theme_recommendations %} + + Suggested themes: + + {% for suggestion in theme_recommendations %} + {{ suggestion.name }} + {% endfor %} + + + {% endif %} {% if commanders %} {% set pagination_position = 'top' %} {% include "commanders/pagination_controls.html" %} diff --git a/code/web/templates/commanders/row_wireframe.html b/code/web/templates/commanders/row_wireframe.html index f2400b5..ab5dc3c 100644 --- a/code/web/templates/commanders/row_wireframe.html +++ b/code/web/templates/commanders/row_wireframe.html @@ -25,14 +25,17 @@ {% for theme in entry.themes %} {% set summary = theme.summary or 'Summary unavailable' %} - + title="{{ summary }}" + aria-label="{{ theme.name }} theme: {{ summary }}" + data-hover-simple-target="theme" + data-hover-simple-name="{{ theme.name }}" + data-hover-simple-summary="{{ summary }}"> {{ theme.name }} {% endfor %} diff --git a/docker-compose.yml b/docker-compose.yml index 8cf7fce..e087bb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,23 +39,19 @@ services: # RARITY_DIVERSITY_OVER_PENALTY: "-0.5" # ------------------------------------------------------------------ - # Random Build (Alpha) Feature Flags - # RANDOM_MODES: backend enablement (seeded selection endpoints) - # RANDOM_UI: enable Surprise/Reroll controls in UI - # RANDOM_MAX_ATTEMPTS: safety cap on retries for constraints - # RANDOM_TIMEOUT_MS: per-attempt timeout (ms) before giving up + # Random Mode Feature Flags # ------------------------------------------------------------------ # Random Modes (feature flags) - RANDOM_MODES: "1" # 1=enable random build endpoints and backend features - RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI + # RANDOM_MODES: "1" # 1=enable random build endpoints and backend features + # RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme) RANDOM_PRIMARY_THEME: "" # optional primary theme slug override RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override - RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes + RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL) RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL) RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1 diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index bb46ecc..a8870d8 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -5,24 +5,29 @@ services: image: mwisnowski/mtg-python-deckbuilder:latest container_name: mtg-deckbuilder-web ports: - - "8080:8080" # Host:Container — open http://localhost:8080 + - "8080:8080" environment: PYTHONUNBUFFERED: "1" TERM: "xterm-256color" DEBIAN_FRONTEND: "noninteractive" + # ------------------------------------------------------------------ # Core UI Feature Toggles + # (Enable/disable visibility of sections; most default to off in code) # ------------------------------------------------------------------ + + # UI features/flags SHOW_LOGS: "1" # 1=enable /logs page; 0=hide - SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide - SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf + SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1) + SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental) - ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide + ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied) ENABLE_PRESETS: "0" # 1=show presets section WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 - ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable - WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics - # Sampling experiments (optional) + ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable + SHOW_MISC_POOL: "0" + WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics + # Sampling experiments # SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count # SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling # Rarity weighting (advanced; default weights tuned for variety) @@ -36,81 +41,106 @@ services: # RARITY_DIVERSITY_OVER_PENALTY: "-0.5" # ------------------------------------------------------------------ - # Random Build (Alpha) Feature Flags + # Random Mode Feature Flags # ------------------------------------------------------------------ - RANDOM_MODES: "0" # 1=backend random build endpoints - RANDOM_UI: "0" # 1=UI Surprise/Reroll controls - RANDOM_MAX_ATTEMPTS: "5" # Retry cap for constrained random builds - RANDOM_TIMEOUT_MS: "5000" # Per-attempt timeout (ms) + + # Random Modes (feature flags) + RANDOM_MODES: "1" # 1=enable random build endpoints and backend features + RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI + RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts + RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme) RANDOM_PRIMARY_THEME: "" # optional primary theme slug override RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override - RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes + RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL) RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL) - RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches when 1 + RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1 RANDOM_CONSTRAINTS: "" # inline JSON constraints for random builds (optional) RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence) RANDOM_SEED: "" # deterministic random seed (int or string) - RANDOM_OUTPUT_JSON: "" # path or directory for random build payload export + RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload + # RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior) # Theming - THEME: "system" # system|light|dark default theme + THEME: "dark" # system|light|dark # ------------------------------------------------------------------ - # Setup / Tagging / Catalog + # Setup / Tagging / Catalog Controls + # WEB_AUTO_SETUP: auto-run initial tagging & theme generation when needed + # WEB_AUTO_REFRESH_DAYS: refresh card data if older than N days (0=never) + # WEB_TAG_PARALLEL + WEB_TAG_WORKERS: parallel tag extraction + # THEME_CATALOG_MODE: merge (Phase B) | legacy | build | phaseb (merge synonyms) + # WEB_AUTO_ENFORCE: 1=run bracket/legal compliance auto-export JSON after builds + # WEB_CUSTOM_EXPORT_BASE: override export path base (optional) + # APP_VERSION: surfaced in UI/health endpoints # ------------------------------------------------------------------ - WEB_AUTO_SETUP: "1" # Auto-run setup/tagging on demand - WEB_AUTO_REFRESH_DAYS: "7" # Refresh card data if stale (days; 0=never) - WEB_TAG_PARALLEL: "1" # Parallel tag extraction on - WEB_TAG_WORKERS: "4" # Worker count (CPU bound; tune as needed) - THEME_CATALOG_MODE: "merge" # Phase B merged theme builder - THEME_YAML_FAST_SKIP: "0" # 1=allow skipping YAML export on fast path (default 0 = always export) + + # Setup/Tagging performance + WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed + WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never + WEB_TAG_PARALLEL: "1" # 1=parallelize tagging + WEB_TAG_WORKERS: "4" # Worker count when parallel tagging + THEME_CATALOG_MODE: "merge" # Use merged Phase B catalog builder (with YAML export) + THEME_YAML_FAST_SKIP: "0" # 1=allow skipping per-theme YAML on fast path (rare; default always export) # Live YAML scan interval in seconds for change detection (dev convenience) # THEME_CATALOG_YAML_SCAN_INTERVAL_SEC: "2.0" # Prewarm common theme filters at startup (speeds first interactions) # WEB_THEME_FILTER_PREWARM: "0" - WEB_AUTO_ENFORCE: "0" # 1=auto compliance JSON export after builds - WEB_CUSTOM_EXPORT_BASE: "" # Optional export base override - APP_VERSION: "v2.3.2" # Displayed in footer/health + WEB_AUTO_ENFORCE: "0" # 1=auto-run compliance export after builds + WEB_CUSTOM_EXPORT_BASE: "" # Optional: custom base dir for deck export artifacts + APP_VERSION: "2.3.2" # Displayed version label (set per release/tag) # ------------------------------------------------------------------ - # Misc Land Selection Tuning (Step 7) + # Misc / Land Selection (Step 7) Environment Tuning + # Uncomment to fine-tune utility land heuristics. Theme weighting allows + # matching candidate lands to selected themes for bias. # ------------------------------------------------------------------ - # MISC_LAND_DEBUG: "1" # Write debug CSVs (diagnostics only) - # MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" - # MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" - # MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Fallback if MIN/MAX unset - # MISC_LAND_THEME_MATCH_BASE: "1.4" - # MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" - # MISC_LAND_THEME_MATCH_CAP: "2.0" + + # Misc land tuning (utility land selection – Step 7) + # MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1 + # MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build + # MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (0–1) for dynamic EDHREC keep range + # MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (used only if MIN/MAX not both provided) + # (Optional theme weighting overrides) + # MISC_LAND_THEME_MATCH_BASE: "1.4" # Multiplier if at least one theme tag matches + # MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # Increment per extra matching tag beyond first + # MISC_LAND_THEME_MATCH_CAP: "2.0" # Cap for total theme multiplier # ------------------------------------------------------------------ - # Path Overrides + # Deck Export / Directory Overrides (headless & web browsing paths) + # DECK_EXPORTS / DECK_CONFIG: override mount points inside container + # OWNED_CARDS_DIR / CARD_LIBRARY_DIR: inventory upload path (alias preserved) # ------------------------------------------------------------------ - # DECK_EXPORTS: "/app/deck_files" - # DECK_CONFIG: "/app/config" - # OWNED_CARDS_DIR: "/app/owned_cards" - # CARD_LIBRARY_DIR: "/app/owned_cards" # legacy alias + + # Paths (optional overrides) + # DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports + # DECK_CONFIG: "/app/config" # Where the config browser looks for *.json + # OWNED_CARDS_DIR: "/app/owned_cards" # Preferred path for owned inventory uploads + # CARD_LIBRARY_DIR: "/app/owned_cards" # Back-compat alias for OWNED_CARDS_DIR # CSV base directory override (useful for testing with frozen snapshots) # CSV_FILES_DIR: "/app/csv_files" # Inject a one-off synthetic CSV for index testing without altering shards # CARD_INDEX_EXTRA_CSV: "" # ------------------------------------------------------------------ - # Headless / CLI Mode (optional automation) + # Headless / Non-interactive Build Configuration + # Provide commander or tag indices/names; toggles for which phases to include + # Counts optionally tune land/fetch/ramp/etc targets. # ------------------------------------------------------------------ - # DECK_MODE: "headless" - # HEADLESS_EXPORT_JSON: "1" - # DECK_COMMANDER: "" - # DECK_PRIMARY_CHOICE: "1" - # DECK_SECONDARY_CHOICE: "" - # DECK_TERTIARY_CHOICE: "" - # DECK_PRIMARY_TAG: "" + + # Headless-only settings + # DECK_MODE: "headless" # Auto-run headless flow in CLI mode + # HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON + # DECK_COMMANDER: "" # Commander name query + # DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based) + # DECK_SECONDARY_CHOICE: "" # Optional secondary index + # DECK_TERTIARY_CHOICE: "" # Optional tertiary index + # DECK_PRIMARY_TAG: "" # Or tag names instead of indices # DECK_SECONDARY_TAG: "" # DECK_TERTIARY_TAG: "" - # DECK_BRACKET_LEVEL: "3" + # DECK_BRACKET_LEVEL: "3" # 1–5 # DECK_ADD_LANDS: "1" # DECK_ADD_CREATURES: "1" # DECK_ADD_NON_CREATURE_SPELLS: "1" @@ -123,32 +153,37 @@ services: # DECK_DUAL_COUNT: "" # DECK_TRIPLE_COUNT: "" # DECK_UTILITY_COUNT: "" - # DECK_TAG_MODE: "AND" + # DECK_TAG_MODE: "AND" # AND|OR (if supported) # HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build + # Entrypoint knobs (only if you change the entrypoint behavior) + # APP_MODE: "web" # web|cli — selects uvicorn vs CLI + # HOST: "0.0.0.0" # Uvicorn bind host + # PORT: "8080" # Uvicorn port + # WORKERS: "1" # Uvicorn workers + # (HOST/PORT honored by entrypoint; WORKERS for multi-worker uvicorn if desired) + # ------------------------------------------------------------------ - # Entrypoint / Server knobs + # Testing / Diagnostics Specific (rarely changed in compose) + # SHOW_MISC_POOL: "1" # (already above) expose misc pool debug UI if implemented # ------------------------------------------------------------------ - # APP_MODE: "web" # web|cli - # HOST: "0.0.0.0" # Bind host - # PORT: "8080" # Uvicorn port - # WORKERS: "1" # Uvicorn workers - + # ------------------------------------------------------------------ - # Editorial / Theme Catalog Controls (advanced / optional) - # These are primarily for maintainers refining automated theme - # descriptions & popularity analytics. Leave commented for normal use. + # Editorial / Theme Catalog Controls + # These drive automated description generation, popularity bucketing, + # YAML backfilling, and regression / metrics exports. Normally only + # used during catalog curation or CI. # ------------------------------------------------------------------ - # EDITORIAL_SEED: "1234" # Deterministic seed for reproducible ordering. - # EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes. - # EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket thresholds (4 ints). - # EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json. - # EDITORIAL_BACKFILL_YAML: "0" # 1=write description/popularity back to YAML (missing only). - # EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include fallback description usage summary in JSON. - # EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint) 1=fail if any theme lacks description. - # EDITORIAL_REQUIRE_POPULARITY: "0" # (lint) 1=fail if any theme lacks popularity bucket. - # EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated examples target. - # EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) enforce above threshold vs warn. + # EDITORIAL_SEED: "1234" # Deterministic seed for description & inference ordering. + # EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes (<2 curated/enforced). + # EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket boundaries (4 comma ints). + # EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json alongside theme_list.json. + # EDITORIAL_BACKFILL_YAML: "0" # 1=enable YAML metadata backfill (description/popularity) on build. + # EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include description_fallback_summary block in JSON output. + # EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint script) 1=fail if a theme lacks description. + # EDITORIAL_REQUIRE_POPULARITY: "0" # (lint script) 1=fail if a theme lacks popularity bucket. + # EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated example commanders/cards (guard rails). + # EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) 1=enforce above threshold; else warn only. # ------------------------------------------------------------------ # Theme Preview Cache & Redis (optional) @@ -171,11 +206,12 @@ services: # THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression # Redis backend (optional) # THEME_PREVIEW_REDIS_URL: "redis://redis:6379/0" - # THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set + # THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set volumes: - ${PWD}/deck_files:/app/deck_files - ${PWD}/logs:/app/logs - ${PWD}/csv_files:/app/csv_files - ${PWD}/config:/app/config - ${PWD}/owned_cards:/app/owned_cards - restart: unless-stopped \ No newline at end of file + working_dir: /app + restart: "no"