Merge pull request #23 from mwisnowski/features/commander-browser

feat(web): refine commander search and theme UX
This commit is contained in:
mwisnowski 2025-10-01 10:59:33 -07:00 committed by GitHub
commit f48e335e17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 764 additions and 116 deletions

View file

@ -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. - 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. - 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 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. - 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. - 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. - Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts.

View file

@ -18,6 +18,12 @@
- Commander hover preview shows card-only panel in browser context and removes the “+ more” overflow badge from theme chips. - 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. - 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 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. - 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. - Preview performance CI check now waits for service health and retries catalog pagination fetches to smooth out transient 500s on cold boots.

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import csv
import json
import time import time
from pathlib import Path from pathlib import Path
@ -69,3 +71,61 @@ def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytes
updated = loader.load_commander_catalog() updated = loader.load_commander_catalog()
assert updated is not first assert updated is not first
assert "zada-hedron-grinder" in updated.by_slug 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

View file

@ -76,7 +76,15 @@ def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> N
search_haystack=f"{name.lower()}" search_haystack=f"{name.lower()}"
) )
records.append(record) 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: def loader() -> SimpleNamespace:
return fake_catalog return fake_catalog
@ -120,13 +128,7 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp
themes=themes, themes=themes,
theme_tokens=tuple(theme.lower() for theme in themes), theme_tokens=tuple(theme.lower() for theme in themes),
) )
fake_catalog = SimpleNamespace(entries=(enriched,)) _install_custom_catalog(monkeypatch, [enriched])
def loader() -> SimpleNamespace:
return fake_catalog
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
response = client.get("/commanders") response = client.get("/commanders")
assert response.status_code == 200 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 assert "commander-theme-chip-more" not in body # no overflow badge rendered
for name in themes: for name in themes:
assert name in body 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

View file

@ -108,9 +108,9 @@ SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True) ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False) ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy) RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False) RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False) THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
def _as_int(val: str | None, default: int) -> int: def _as_int(val: str | None, default: int) -> int:
try: try:

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from difflib import SequenceMatcher
from math import ceil from math import ceil
from typing import Iterable, Mapping, Sequence from typing import Iterable, Mapping, Sequence
from urllib.parse import urlencode from urllib.parse import urlencode
import re
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@ -16,6 +18,11 @@ from ..services.telemetry import log_commander_page_view
router = APIRouter(prefix="/commanders", tags=["commanders"]) router = APIRouter(prefix="/commanders", tags=["commanders"])
PAGE_SIZE = 20 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") _WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
_COLOR_NAMES: dict[str, str] = { _COLOR_NAMES: dict[str, str] = {
@ -76,6 +83,12 @@ class CommanderView:
partner_summary: tuple[str, ...] partner_summary: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class ThemeRecommendation:
name: str
score: float
def _is_htmx(request: Request) -> bool: def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request", "").lower() == "true" 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) items = list(records)
color_code = _canon_color_code(color) color_code = _canon_color_code(color)
if color_code: if color_code:
items = [rec for rec in items if _record_color_code(rec) == color_code] items = [rec for rec in items if _record_color_code(rec) == color_code]
if q: normalized_query = _normalize_search_text(q)
lowered = q.lower().strip() if normalized_query:
if lowered: filtered: list[tuple[float, CommanderRecord]] = []
tokens = [tok for tok in lowered.split() if tok] for rec in items:
if tokens: score = _commander_name_match_score(normalized_query, rec)
filtered: list[CommanderRecord] = [] if score >= _MIN_NAME_MATCH_SCORE:
for rec in items: filtered.append((score, rec))
haystack = rec.search_haystack or "" if filtered:
if all(tok in haystack for tok in tokens): filtered.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower()))
filtered.append(rec) items = [rec for _, rec in filtered]
items = 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 return items
@ -226,7 +420,11 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
try: try:
data = idx.summary_by_slug.get(slug) data = idx.summary_by_slug.get(slug)
if data: 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: except Exception:
summary = None summary = None
info[name] = CommanderTheme(name=name, slug=slug, summary=summary) 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( async def commanders_index(
request: Request, request: Request,
q: str | None = Query(default=None, alias="q"), q: str | None = Query(default=None, alias="q"),
theme: str | None = Query(default=None, alias="theme"),
color: str | None = Query(default=None, alias="color"), color: str | None = Query(default=None, alias="color"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
) -> HTMLResponse: ) -> HTMLResponse:
@ -247,7 +446,10 @@ async def commanders_index(
entries = catalog.entries entries = catalog.entries
except FileNotFoundError: except FileNotFoundError:
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists." 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) total_filtered = len(filtered)
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1 page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
if page > page_count: if page > page_count:
@ -268,6 +470,8 @@ async def commanders_index(
params: dict[str, str] = {} params: dict[str, str] = {}
if q: if q:
params["q"] = q params["q"] = q
if theme_query:
params["theme"] = theme_query
if canon_color: if canon_color:
params["color"] = canon_color params["color"] = canon_color
params["page"] = str(page_value) params["page"] = str(page_value)
@ -289,8 +493,11 @@ async def commanders_index(
"request": request, "request": request,
"commanders": views, "commanders": views,
"query": q or "", "query": q or "",
"theme_query": theme_query,
"color": canon_color, "color": canon_color,
"color_options": color_options, "color_options": color_options,
"theme_options": theme_names,
"theme_recommendations": theme_recommendations,
"total_count": len(entries), "total_count": len(entries),
"result_count": len(views), "result_count": len(views),
"result_total": total_filtered, "result_total": total_filtered,
@ -305,7 +512,7 @@ async def commanders_index(
"next_page": next_page, "next_page": next_page,
"prev_url": prev_url, "prev_url": prev_url,
"next_url": next_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, "error": error,
"return_url": return_url, "return_url": return_url,
} }

View file

@ -49,6 +49,7 @@ _COLOR_ALIAS = {
} }
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G") _WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G")
_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image" _SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image"
_THEME_ESCAPE_PATTERN = re.compile(r"\\([+/\\-])")
@dataclass(frozen=True, slots=True) @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 power = _clean_str(row.get("power")) or None
toughness = _clean_str(row.get("toughness")) or None toughness = _clean_str(row.get("toughness")) or None
keywords = tuple(_split_to_list(row.get("keywords"))) 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)) theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
edhrec_rank = _parse_int(row.get("edhrecRank")) edhrec_rank = _parse_int(row.get("edhrecRank"))
layout = _clean_str(row.get("layout")) or "normal" 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] 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]: def _split_to_list(value: object) -> List[str]:
text = _clean_str(value) text = _clean_str(value)
if not text: if not text:

View file

@ -13,16 +13,25 @@
method="get" method="get"
hx-get="/commanders" hx-get="/commanders"
hx-target="#commander-results" 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-include="#commander-filter-form"
hx-push-url="true" hx-push-url="true"
hx-indicator="#commander-loading" hx-indicator="#commander-loading"
novalidate novalidate
> >
<label> <label>
<span class="filter-label">Search</span> <span class="filter-label">Commander name</span>
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commanders, themes, or text..." autocomplete="off" /> <input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commander names..." autocomplete="off" />
</label> </label>
<label>
<span class="filter-label">Theme</span>
<input type="search" id="commander-theme" name="theme" value="{{ theme_query }}" placeholder="Search themes..." list="theme-suggestions" autocomplete="off" />
</label>
<datalist id="theme-suggestions">
{% for name in theme_options[:200] %}
<option value="{{ name }}"></option>
{% endfor %}
</datalist>
<label> <label>
<span class="filter-label">Color identity</span> <span class="filter-label">Color identity</span>
<select id="commander-color" name="color"> <select id="commander-color" name="color">
@ -89,9 +98,9 @@
.commander-name { margin:0; font-size:1.25rem; } .commander-name { margin:0; font-size:1.25rem; }
.color-identity { display:flex; align-items:center; gap:.35rem; } .color-identity { display:flex; align-items:center; gap:.35rem; }
.commander-context { margin:0; font-size:.95rem; } .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-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: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-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; } .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],
.commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; } .commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; }
.commander-pagination-status { font-size:.85rem; color:var(--muted); } .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 { display:none; margin-top:1rem; }
.commander-loading.htmx-request { display:block; } .commander-loading.htmx-request { display:block; }
@ -124,6 +139,17 @@
0% { background-position:100% 0; } 0% { background-position:100% 0; }
100% { 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) { @media (max-width: 900px) {
.commander-row { flex-direction:column; } .commander-row { flex-direction:column; }
@ -148,10 +174,106 @@
if (!pageInput) return; if (!pageInput) return;
const resetPage = () => { pageInput.value = '1'; }; const resetPage = () => { pageInput.value = '1'; };
const setLastTrigger = (value) => { form.dataset.lastTrigger = value; };
const searchField = document.getElementById('commander-search'); const searchField = document.getElementById('commander-search');
const colorField = document.getElementById('commander-color'); const colorField = document.getElementById('commander-color');
if (searchField) searchField.addEventListener('input', resetPage); const themeField = document.getElementById('commander-theme');
if (colorField) colorField.addEventListener('change', resetPage); 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 = `
<div class="commander-theme-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="commander-theme-dialog-title">
<h3 class="commander-theme-dialog__title" id="commander-theme-dialog-title"></h3>
<p class="commander-theme-dialog__body"></p>
<button type="button" class="btn commander-theme-dialog__close">Close</button>
</div>
`;
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) => { const updatePageFromResults = (container) => {
if (!container) return; if (!container) return;
@ -170,6 +292,11 @@
const container = document.getElementById('commander-results'); const container = document.getElementById('commander-results');
const searchEl = document.getElementById('commander-search'); const searchEl = document.getElementById('commander-search');
if (!container) return; 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 invoker = event.detail && event.detail.elt ? event.detail.elt : null;
const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]'); 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 // 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')); 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();
}
});
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View file

@ -13,6 +13,16 @@
No commander data available. No commander data available.
{% endif %} {% endif %}
</div> </div>
{% if theme_query and theme_recommendations %}
<div class="theme-recommendations" data-theme-recommendations>
<span class="theme-recommendations-label">Suggested themes:</span>
<div class="theme-recommendations-chips">
{% for suggestion in theme_recommendations %}
<button type="button" class="theme-suggestion-chip" data-theme-suggestion="{{ suggestion.name }}" data-theme-score="{{ '%.2f'|format(suggestion.score) }}">{{ suggestion.name }}</button>
{% endfor %}
</div>
</div>
{% endif %}
{% if commanders %} {% if commanders %}
{% set pagination_position = 'top' %} {% set pagination_position = 'top' %}
{% include "commanders/pagination_controls.html" %} {% include "commanders/pagination_controls.html" %}

View file

@ -25,14 +25,17 @@
<div class="commander-themes" role="list"> <div class="commander-themes" role="list">
{% for theme in entry.themes %} {% for theme in entry.themes %}
{% set summary = theme.summary or 'Summary unavailable' %} {% set summary = theme.summary or 'Summary unavailable' %}
<button type="button" <button type="button"
class="commander-theme-chip" class="commander-theme-chip"
role="listitem" role="listitem"
data-theme-name="{{ theme.name }}" data-theme-name="{{ theme.name }}"
data-theme-slug="{{ theme.slug }}" data-theme-slug="{{ theme.slug }}"
data-theme-summary="{{ summary }}" data-theme-summary="{{ summary }}"
title="{{ summary }}" title="{{ summary }}"
aria-label="{{ theme.name }} theme: {{ 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 }} {{ theme.name }}
</button> </button>
{% endfor %} {% endfor %}

View file

@ -39,23 +39,19 @@ services:
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5" # RARITY_DIVERSITY_OVER_PENALTY: "-0.5"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Random Build (Alpha) Feature Flags # Random Mode 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 Modes (feature flags) # Random Modes (feature flags)
RANDOM_MODES: "1" # 1=enable random build endpoints and backend features # RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI # RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme) RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary 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_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_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 RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1

View file

@ -5,24 +5,29 @@ services:
image: mwisnowski/mtg-python-deckbuilder:latest image: mwisnowski/mtg-python-deckbuilder:latest
container_name: mtg-deckbuilder-web container_name: mtg-deckbuilder-web
ports: ports:
- "8080:8080" # Host:Container — open http://localhost:8080 - "8080:8080"
environment: environment:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
TERM: "xterm-256color" TERM: "xterm-256color"
DEBIAN_FRONTEND: "noninteractive" DEBIAN_FRONTEND: "noninteractive"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Core UI Feature Toggles # 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_LOGS: "1" # 1=enable /logs page; 0=hide
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide 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 SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental) 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 ENABLE_PRESETS: "0" # 1=show presets section
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics SHOW_MISC_POOL: "0"
# Sampling experiments (optional) 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: "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 # 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) # Rarity weighting (advanced; default weights tuned for variety)
@ -36,81 +41,106 @@ services:
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5" # 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 Modes (feature flags)
RANDOM_MAX_ATTEMPTS: "5" # Retry cap for constrained random builds RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
RANDOM_TIMEOUT_MS: "5000" # Per-attempt timeout (ms) 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_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary 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_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_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: "" # inline JSON constraints for random builds (optional)
RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence) RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence)
RANDOM_SEED: "" # deterministic random seed (int or string) 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 # 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) # Setup/Tagging performance
WEB_TAG_PARALLEL: "1" # Parallel tag extraction on WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
WEB_TAG_WORKERS: "4" # Worker count (CPU bound; tune as needed) WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
THEME_CATALOG_MODE: "merge" # Phase B merged theme builder WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping YAML export on fast path (default 0 = always export) 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) # Live YAML scan interval in seconds for change detection (dev convenience)
# THEME_CATALOG_YAML_SCAN_INTERVAL_SEC: "2.0" # THEME_CATALOG_YAML_SCAN_INTERVAL_SEC: "2.0"
# Prewarm common theme filters at startup (speeds first interactions) # Prewarm common theme filters at startup (speeds first interactions)
# WEB_THEME_FILTER_PREWARM: "0" # WEB_THEME_FILTER_PREWARM: "0"
WEB_AUTO_ENFORCE: "0" # 1=auto compliance JSON export after builds WEB_AUTO_ENFORCE: "0" # 1=auto-run compliance export after builds
WEB_CUSTOM_EXPORT_BASE: "" # Optional export base override WEB_CUSTOM_EXPORT_BASE: "" # Optional: custom base dir for deck export artifacts
APP_VERSION: "v2.3.2" # Displayed in footer/health 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 tuning (utility land selection Step 7)
# MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # 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: "0.80" # Fallback if MIN/MAX unset # MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (01). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build
# MISC_LAND_THEME_MATCH_BASE: "1.4" # MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (01) for dynamic EDHREC keep range
# MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (used only if MIN/MAX not both provided)
# MISC_LAND_THEME_MATCH_CAP: "2.0" # (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" # Paths (optional overrides)
# OWNED_CARDS_DIR: "/app/owned_cards" # DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports
# CARD_LIBRARY_DIR: "/app/owned_cards" # legacy alias # 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 base directory override (useful for testing with frozen snapshots)
# CSV_FILES_DIR: "/app/csv_files" # CSV_FILES_DIR: "/app/csv_files"
# Inject a one-off synthetic CSV for index testing without altering shards # Inject a one-off synthetic CSV for index testing without altering shards
# CARD_INDEX_EXTRA_CSV: "" # 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" # Headless-only settings
# DECK_COMMANDER: "" # DECK_MODE: "headless" # Auto-run headless flow in CLI mode
# DECK_PRIMARY_CHOICE: "1" # HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
# DECK_SECONDARY_CHOICE: "" # DECK_COMMANDER: "" # Commander name query
# DECK_TERTIARY_CHOICE: "" # DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based)
# DECK_PRIMARY_TAG: "" # 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_SECONDARY_TAG: ""
# DECK_TERTIARY_TAG: "" # DECK_TERTIARY_TAG: ""
# DECK_BRACKET_LEVEL: "3" # DECK_BRACKET_LEVEL: "3" # 15
# DECK_ADD_LANDS: "1" # DECK_ADD_LANDS: "1"
# DECK_ADD_CREATURES: "1" # DECK_ADD_CREATURES: "1"
# DECK_ADD_NON_CREATURE_SPELLS: "1" # DECK_ADD_NON_CREATURE_SPELLS: "1"
@ -123,32 +153,37 @@ services:
# DECK_DUAL_COUNT: "" # DECK_DUAL_COUNT: ""
# DECK_TRIPLE_COUNT: "" # DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_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 # HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build
# ------------------------------------------------------------------ # Entrypoint knobs (only if you change the entrypoint behavior)
# Entrypoint / Server knobs # APP_MODE: "web" # web|cli — selects uvicorn vs CLI
# ------------------------------------------------------------------ # HOST: "0.0.0.0" # Uvicorn bind host
# APP_MODE: "web" # web|cli # PORT: "8080" # Uvicorn port
# HOST: "0.0.0.0" # Bind host # WORKERS: "1" # Uvicorn workers
# PORT: "8080" # Uvicorn port # (HOST/PORT honored by entrypoint; WORKERS for multi-worker uvicorn if desired)
# WORKERS: "1" # Uvicorn workers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Editorial / Theme Catalog Controls (advanced / optional) # Testing / Diagnostics Specific (rarely changed in compose)
# These are primarily for maintainers refining automated theme # SHOW_MISC_POOL: "1" # (already above) expose misc pool debug UI if implemented
# descriptions & popularity analytics. Leave commented for normal use.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 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 / Theme Catalog Controls
# EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json. # These drive automated description generation, popularity bucketing,
# EDITORIAL_BACKFILL_YAML: "0" # 1=write description/popularity back to YAML (missing only). # YAML backfilling, and regression / metrics exports. Normally only
# EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include fallback description usage summary in JSON. # used during catalog curation or CI.
# 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_SEED: "1234" # Deterministic seed for description & inference ordering.
# EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated examples target. # EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes (<2 curated/enforced).
# EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) enforce above threshold vs warn. # 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) # Theme Preview Cache & Redis (optional)
@ -171,11 +206,12 @@ services:
# THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression # THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression
# Redis backend (optional) # Redis backend (optional)
# THEME_PREVIEW_REDIS_URL: "redis://redis:6379/0" # 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: volumes:
- ${PWD}/deck_files:/app/deck_files - ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs - ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files - ${PWD}/csv_files:/app/csv_files
- ${PWD}/config:/app/config - ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards - ${PWD}/owned_cards:/app/owned_cards
restart: unless-stopped working_dir: /app
restart: "no"