feat(web): launch commander browser with deck builder CTA

This commit is contained in:
matt 2025-09-30 15:49:08 -07:00
parent 6e9ba244c9
commit 8e57588f40
27 changed files with 1960 additions and 45 deletions

37
.gitignore vendored
View file

@ -1,32 +1,39 @@
*.bkp
*.csv *.csv
*.json *.json
*.log *.log
*.txt *.txt
.mypy_cache/
.venv/
.pytest_cache/
test.py
!requirements.txt !requirements.txt
RELEASE_NOTES.md
test.py
!test_exclude_cards.txt
!test_include_exclude_config.json
.mypy_cache/
.pytest_cache/
.venv/
__pycache__/ __pycache__/
.github/*.md
config/themes/catalog/
config/themes/
!config/themes/*.yml
!config/card_lists/*.json
!config/deck.json
# Keep main CSV datasets out of Git, but allow the tiny deterministic fixtures used by CI. # Keep main CSV datasets out of Git, but allow the tiny deterministic fixtures used by CI.
csv_files/* csv_files/*
!csv_files/testdata/ !csv_files/testdata/
!csv_files/testdata/**/* !csv_files/testdata/**/*
deck_files/
dist/ dist/
logs/ logs/
!logs/ !logs/
logs/* logs/*
!logs/perf/ !logs/perf/
logs/perf/* logs/perf/*
!logs/perf/theme_preview_warm_baseline.json !logs/perf/theme_preview_warm_baseline.json
deck_files/
config/themes/catalog/
!config/card_lists/*.json
!config/themes/*.yml
config/themes/theme_list.json
!config/deck.json
!test_exclude_cards.txt
!test_include_exclude_config.json
RELEASE_NOTES.md
*.bkp
.github/*.md

View file

@ -14,10 +14,19 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
- _No changes yet._ - Commander browser skeleton page at `/commanders` with HTMX-capable filtering and catalog-backed commander rows.
- Shared color-identity macro and accessible theme chips powering the commander browser UI.
- Commander browser QA walkthrough documenting desktop and mobile validation steps (`docs/qa/commander_browser_walkthrough.md`).
- Home screen actions now surface Commander Browser and Diagnostics shortcuts when the corresponding feature flags are enabled.
- Manual QA pass (2025-09-30) recorded in project docs, covering desktop/mobile flows and edge cases.
### Changed ### Changed
- _No changes yet._ - Commander browser now paginates results in 20-commander pages with accessible navigation controls and range summaries to keep the catalog responsive.
- 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 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.
### Fixed ### Fixed
- _No changes yet._ - _No changes yet._

BIN
README.md

Binary file not shown.

View file

@ -1,9 +1,24 @@
# MTG Python Deckbuilder ${VERSION} # MTG Python Deckbuilder ${VERSION}
## Summary ## Summary
- Theme catalog pagination buttons now re-run HTMX processing after fragment fetches so “Next” works reliably in the picker and simple catalog views. - Introduced the Commander Browser with HTMX-powered pagination, theme surfacing, and direct Create Deck integration.
- Docker entrypoint seeds default theme catalog configuration files into volume-backed deployments, keeping Docker Hub images ready out of the box. - Shared color-identity macro and accessible theme chips power the new commander rows.
- Manual QA walkthrough (desktop + mobile) recorded on 20250930 with edge-case checks.
- Home dashboard aligns its quick actions with feature flags, exposing Commanders, Diagnostics, Random, Logs, and Setup where enabled.
## Added
- Commander browser skeleton page at `/commanders` with catalog-backed rows and accessible theme chips.
- Documented QA checklist and results for the commander browser launch in `docs/qa/commander_browser_walkthrough.md`.
- Shared color-identity macro for reusable mana dots across commander rows and other templates.
- Home dashboard Commander/Diagnostics shortcuts gated by feature flags so all primary destinations have quick actions.
- Manual QA pass entered into project docs (2025-09-30) outlining desktop, mobile, and edge-case validations.
## Changed
- Commander list paginates in 20-item pages, with navigation controls mirrored above and below the results and automatic scroll-to-top.
- 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.
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
## Fixed ## Fixed
- Theme catalog Next pagination button now reprocesses HTMX fragments after manual fetches, restoring navigation controls in theme picker and simple catalog modes. - Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures.
- Docker entrypoint seeds default `config/themes` YAML files (synergy pairs, clusters, whitelist, etc.) into mounted volumes so Docker Hub deployments have the expected baseline data.

View file

@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
import html as _html
import re
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
@pytest.fixture
def client(monkeypatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commander_row_has_build_cta_with_return_url(client: TestClient) -> None:
# Load the commanders page
resp = client.get("/commanders", params={"q": "atraxa"})
assert resp.status_code == 200
body = resp.text
# Ensure the Build link includes the builder path with commander and return params
match = re.search(r'href="(/build\?[^\"]+)"', body)
assert match is not None
href = _html.unescape(match.group(1))
assert href.startswith("/build?commander=")
parsed = urlparse(href)
params = parse_qs(parsed.query)
assert "return" in params
return_value = params["return"][0]
assert return_value.startswith("/commanders")
parsed_return = urlparse(return_value)
assert parsed_return.path.rstrip("/") == "/commanders"
parsed_return_params = parse_qs(parsed_return.query)
assert parsed_return_params.get("q") == ["atraxa"]
# Ensure no absolute scheme slipped through
assert not return_value.startswith("http")
def test_build_page_includes_back_link_for_safe_return(client: TestClient) -> None:
resp = client.get("/build", params={"return": "/commanders?page=2&color=W"})
assert resp.status_code == 200
body = resp.text
match = re.search(r'href="(/commanders[^\"]+)"', body)
assert match is not None
href = _html.unescape(match.group(1))
parsed = urlparse(href)
assert parsed.path == "/commanders"
params = parse_qs(parsed.query)
assert params.get("page") == ["2"]
assert params.get("color") == ["W"]
def test_build_page_ignores_external_return(client: TestClient) -> None:
resp = client.get("/build", params={"return": "https://evil.example.com"})
assert resp.status_code == 200
body = resp.text
assert "Back to Commanders" not in body
def test_commander_launch_preselects_commander_and_requires_theme(client: TestClient) -> None:
commander_name = "Atraxa, Praetors' Voice"
resp = client.get(
"/build",
params={"commander": commander_name, "return": "/commanders?page=2"},
)
assert resp.status_code == 200
body = resp.text
init_match = re.search(r'<span id="builder-init"[^>]*data-commander="([^"]+)"', body)
assert init_match is not None
assert _html.unescape(init_match.group(1)) == commander_name
assert "Back to Commanders" in body
step2 = client.get("/build/step2")
assert step2.status_code == 200
step2_body = step2.text
assert commander_name in _html.unescape(step2_body)
assert 'name="primary_tag"' in step2_body
submit = client.post(
"/build/step2",
data={
"commander": commander_name,
"bracket": "3",
"tag_mode": "AND",
},
)
assert submit.status_code == 200
assert "Please choose a primary theme." in submit.text

View file

@ -0,0 +1,71 @@
from __future__ import annotations
import time
from pathlib import Path
import pytest
from web.services import commander_catalog_loader as loader
FIXTURE_DIR = Path(__file__).resolve().parents[2] / "csv_files" / "testdata"
def _set_csv_dir(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
monkeypatch.setenv("CSV_FILES_DIR", str(path))
loader.clear_commander_catalog_cache()
def test_commander_catalog_basic_normalization(monkeypatch: pytest.MonkeyPatch) -> None:
_set_csv_dir(monkeypatch, FIXTURE_DIR)
catalog = loader.load_commander_catalog()
assert catalog.source_path.name == "commander_cards.csv"
assert len(catalog.entries) == 4
krenko = catalog.by_slug["krenko-mob-boss"]
assert krenko.display_name == "Krenko, Mob Boss"
assert krenko.color_identity == ("R",)
assert krenko.color_identity_key == "R"
assert not krenko.is_colorless
assert krenko.themes == ("Goblin Kindred",)
assert "goblin kindred" in krenko.theme_tokens
assert "version=small" in krenko.image_small_url
assert "exact=Krenko%2C%20Mob%20Boss" in krenko.image_small_url
traxos = catalog.by_slug["traxos-scourge-of-kroog"]
assert traxos.is_colorless
assert traxos.color_identity == ()
assert traxos.color_identity_key == "C"
atraxa = catalog.by_slug["atraxa-praetors-voice"]
assert atraxa.color_identity == ("W", "U", "B", "G")
assert atraxa.color_identity_key == "WUBG"
assert atraxa.is_partner is False
assert atraxa.supports_backgrounds is False
def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
fixture_csv = FIXTURE_DIR / "commander_cards.csv"
work_dir = tmp_path / "csv"
work_dir.mkdir()
target_csv = work_dir / "commander_cards.csv"
target_csv.write_text(fixture_csv.read_text(encoding="utf-8"), encoding="utf-8")
_set_csv_dir(monkeypatch, work_dir)
first = loader.load_commander_catalog()
again = loader.load_commander_catalog()
assert again is first
time.sleep(1.1) # ensure mtime tick on systems with 1s resolution
target_csv.write_text(
fixture_csv.read_text(encoding="utf-8")
+ "\"Zada, Hedron Grinder\",\"Zada, Hedron Grinder\",9999,R,R,{3}{R},4,\"Legendary Creature — Goblin\",\"['Goblin']\",\"Test\",3,3,,\"['Goblin Kindred']\",normal,\n",
encoding="utf-8",
)
updated = loader.load_commander_catalog()
assert updated is not first
assert "zada-hedron-grinder" in updated.by_slug

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.services import telemetry
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commander_page_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
events: list[dict] = []
def capture(_logger, payload):
events.append(payload)
monkeypatch.setattr(telemetry, "_emit", capture)
response = client.get("/commanders", params={"q": "atraxa"})
assert response.status_code == 200
assert events, "expected telemetry events to be emitted"
event = events[-1]
assert event["event"] == "commander_browser.page_view"
assert event["page"] == 1
assert event["query"]["q"] == "atraxa"
assert event["is_htmx"] is False
def test_commander_create_deck_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
events: list[dict] = []
def capture(_logger, payload):
events.append(payload)
monkeypatch.setattr(telemetry, "_emit", capture)
response = client.get("/build", params={"commander": "Atraxa", "return": "/commanders"})
assert response.status_code == 200
assert events, "expected telemetry events to be emitted"
event = events[-1]
assert event["event"] == "commander_browser.create_deck"
assert event["commander"] == "Atraxa"
assert event["has_return"] is True
assert event["return_url"] == "/commanders"

View file

@ -0,0 +1,137 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
from code.web.routes import commanders
from code.web.services import commander_catalog_loader
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog
@pytest.fixture
def client(monkeypatch):
csv_dir = Path("csv_files/testdata").resolve()
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
clear_commander_catalog_cache()
with TestClient(app) as test_client:
yield test_client
clear_commander_catalog_cache()
def test_commanders_page_renders(client: TestClient) -> None:
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"atraxa-praetors-voice\"" in body
assert "data-commander-slug=\"krenko-mob-boss\"" in body
assert "data-theme-summary=\"" in body
assert 'id="commander-loading"' in body
def test_commanders_search_filters(client: TestClient) -> None:
response = client.get("/commanders", params={"q": "krenko"})
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"krenko-mob-boss\"" in body
assert "data-commander-slug=\"atraxa-praetors-voice\"" not in body
def test_commanders_color_filter(client: TestClient) -> None:
response = client.get("/commanders", params={"color": "W"})
assert response.status_code == 200
body = response.text
assert "data-commander-slug=\"isamaru-hound-of-konda\"" in body
assert "data-commander-slug=\"krenko-mob-boss\"" not in body
def test_commanders_htmx_fragment(client: TestClient) -> None:
response = client.get(
"/commanders",
params={"q": "atraxa"},
headers={"HX-Request": "true"},
)
assert response.status_code == 200
body = response.text
assert "commander-row" in body
assert "<section class=\"commander-page\"" not in body
def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> None:
base_catalog = load_commander_catalog()
sample = base_catalog.entries[0]
records = []
for index in range(total):
name = f"Pagination Test {index:02d}"
record = replace(
sample,
name=name,
face_name=name,
display_name=name,
slug=f"pagination-test-{index:02d}",
search_haystack=f"{name.lower()}"
)
records.append(record)
fake_catalog = SimpleNamespace(entries=tuple(records))
def loader() -> SimpleNamespace:
return fake_catalog
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
def test_commanders_pagination_limits_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_install_paginated_catalog(monkeypatch, total=35)
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "Page 1 of 2" in body
assert "Showing 1&nbsp;&ndash;&nbsp;20 of 35" in body
assert body.count('href="/commanders?page=2"') == 2
assert body.count('data-commander-slug="pagination-test-') == 20
def test_commanders_second_page_shows_remaining_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
_install_paginated_catalog(monkeypatch, total=35)
response = client.get("/commanders", params={"page": 2})
assert response.status_code == 200
body = response.text
assert "Page 2 of 2" in body
assert 'data-commander-slug="pagination-test-00"' not in body
assert 'data-commander-slug="pagination-test-20"' in body
assert 'data-commander-slug="pagination-test-34"' in body
assert 'href="/commanders?page=1"' in body
def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
themes = tuple(f"Theme {idx}" for idx in range(1, 9))
enriched = replace(
sample,
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)
response = client.get("/commanders")
assert response.status_code == 200
body = response.text
assert "commander-theme-chip-more" not in body # no overflow badge rendered
for name in themes:
assert name in body

View file

@ -8,6 +8,18 @@ fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installe
def load_app_with_env(**env: str) -> types.ModuleType: def load_app_with_env(**env: str) -> types.ModuleType:
for key in (
"SHOW_LOGS",
"SHOW_DIAGNOSTICS",
"SHOW_SETUP",
"SHOW_COMMANDERS",
"ENABLE_THEMES",
"ENABLE_PWA",
"ENABLE_PRESETS",
"APP_VERSION",
"THEME",
):
os.environ.pop(key, None)
for k, v in env.items(): for k, v in env.items():
os.environ[k] = v os.environ[k] = v
import code.web.app as app_module # type: ignore import code.web.app as app_module # type: ignore
@ -67,6 +79,7 @@ def test_status_sys_summary_and_flags():
SHOW_LOGS="1", SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1", SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1", SHOW_SETUP="1",
SHOW_COMMANDERS="1",
ENABLE_THEMES="1", ENABLE_THEMES="1",
ENABLE_PWA="1", ENABLE_PWA="1",
ENABLE_PRESETS="1", ENABLE_PRESETS="1",
@ -84,8 +97,27 @@ def test_status_sys_summary_and_flags():
assert flags.get("SHOW_LOGS") is True assert flags.get("SHOW_LOGS") is True
assert flags.get("SHOW_DIAGNOSTICS") is True assert flags.get("SHOW_DIAGNOSTICS") is True
assert flags.get("SHOW_SETUP") is True assert flags.get("SHOW_SETUP") is True
assert flags.get("SHOW_COMMANDERS") is True
# Theme-related flags # Theme-related flags
assert flags.get("ENABLE_THEMES") is True assert flags.get("ENABLE_THEMES") is True
assert flags.get("ENABLE_PWA") is True assert flags.get("ENABLE_PWA") is True
assert flags.get("ENABLE_PRESETS") is True assert flags.get("ENABLE_PRESETS") is True
assert flags.get("DEFAULT_THEME") == "dark" assert flags.get("DEFAULT_THEME") == "dark"
def test_commanders_nav_hidden_when_flag_disabled():
app_module = load_app_with_env(SHOW_COMMANDERS="0")
client = TestClient(app_module.app)
r = client.get("/")
assert r.status_code == 200
body = r.text
assert '<a href="/commanders"' not in body
def test_commanders_nav_visible_by_default():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/")
assert r.status_code == 200
body = r.text
assert '<a href="/commanders"' in body

View file

@ -0,0 +1,61 @@
import os
import importlib
import types
from starlette.testclient import TestClient
def load_app_with_env(**env: str) -> types.ModuleType:
for key in (
"SHOW_LOGS",
"SHOW_DIAGNOSTICS",
"SHOW_SETUP",
"SHOW_COMMANDERS",
"ENABLE_THEMES",
"ENABLE_PWA",
"ENABLE_PRESETS",
"APP_VERSION",
"THEME",
"RANDOM_UI",
):
os.environ.pop(key, None)
for k, v in env.items():
os.environ[k] = v
import code.web.app as app_module # type: ignore
importlib.reload(app_module)
return app_module
def test_home_actions_show_all_enabled_buttons():
app_module = load_app_with_env(
SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1",
SHOW_COMMANDERS="1",
RANDOM_UI="1",
)
client = TestClient(app_module.app)
response = client.get("/")
body = response.text
assert 'href="/setup"' in body
assert 'href="/commanders"' in body
assert 'href="/random"' in body
assert 'href="/diagnostics"' in body
assert 'href="/logs"' in body
def test_home_actions_hides_disabled_sections():
app_module = load_app_with_env(
SHOW_LOGS="0",
SHOW_DIAGNOSTICS="0",
SHOW_SETUP="0",
SHOW_COMMANDERS="0",
RANDOM_UI="0",
)
client = TestClient(app_module.app)
response = client.get("/")
body = response.text
assert 'href="/setup"' not in body
assert 'href="/commanders"' not in body
assert 'href="/random"' not in body
assert 'href="/diagnostics"' not in body
assert 'href="/logs"' not in body

View file

@ -103,6 +103,7 @@ def _as_bool(val: str | None, default: bool = False) -> bool:
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False) SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True) SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False) SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
SHOW_COMMANDERS = _as_bool(os.getenv("SHOW_COMMANDERS"), True)
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False) SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False) ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False) ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
@ -231,6 +232,7 @@ templates.env.globals.update({
"show_logs": SHOW_LOGS, "show_logs": SHOW_LOGS,
"show_setup": SHOW_SETUP, "show_setup": SHOW_SETUP,
"show_diagnostics": SHOW_DIAGNOSTICS, "show_diagnostics": SHOW_DIAGNOSTICS,
"show_commanders": SHOW_COMMANDERS,
"virtualize": SHOW_VIRTUALIZE, "virtualize": SHOW_VIRTUALIZE,
"enable_themes": ENABLE_THEMES, "enable_themes": ENABLE_THEMES,
"enable_pwa": ENABLE_PWA, "enable_pwa": ENABLE_PWA,
@ -815,6 +817,7 @@ async def status_sys():
"flags": { "flags": {
"SHOW_LOGS": bool(SHOW_LOGS), "SHOW_LOGS": bool(SHOW_LOGS),
"SHOW_SETUP": bool(SHOW_SETUP), "SHOW_SETUP": bool(SHOW_SETUP),
"SHOW_COMMANDERS": bool(SHOW_COMMANDERS),
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS), "SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
"ENABLE_THEMES": bool(ENABLE_THEMES), "ENABLE_THEMES": bool(ENABLE_THEMES),
"ENABLE_PWA": bool(ENABLE_PWA), "ENABLE_PWA": bool(ENABLE_PWA),
@ -2128,12 +2131,14 @@ from .routes import decks as decks_routes # noqa: E402
from .routes import setup as setup_routes # noqa: E402 from .routes import setup as setup_routes # noqa: E402
from .routes import owned as owned_routes # noqa: E402 from .routes import owned as owned_routes # noqa: E402
from .routes import themes as themes_routes # noqa: E402 from .routes import themes as themes_routes # noqa: E402
from .routes import commanders as commanders_routes # noqa: E402
app.include_router(build_routes.router) app.include_router(build_routes.router)
app.include_router(config_routes.router) app.include_router(config_routes.router)
app.include_router(decks_routes.router) app.include_router(decks_routes.router)
app.include_router(setup_routes.router) app.include_router(setup_routes.router)
app.include_router(owned_routes.router) app.include_router(owned_routes.router)
app.include_router(themes_routes.router) app.include_router(themes_routes.router)
app.include_router(commanders_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev # Warm validation cache early to reduce first-call latency in tests and dev
try: try:
@ -2190,6 +2195,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
"error": True, "error": True,
"status": exc.status_code, "status": exc.status_code,
"detail": exc.detail, "detail": exc.detail,
"request_id": rid,
"path": str(request.url.path), "path": str(request.url.path),
}, headers=headers) }, headers=headers)

View file

@ -25,6 +25,8 @@ from deck_builder import builder_utils as bu
from ..services.combo_utils import detect_all as _detect_all from ..services.combo_utils import detect_all as _detect_all
from path_util import csv_dir as _csv_dir from path_util import csv_dir as _csv_dir
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
from ..services.telemetry import log_commander_create_deck
from urllib.parse import urlparse
# Cache for available card names used by validation endpoints # Cache for available card names used by validation endpoints
_AVAILABLE_CARDS_CACHE: set[str] | None = None _AVAILABLE_CARDS_CACHE: set[str] | None = None
@ -188,6 +190,39 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
async def build_index(request: Request) -> HTMLResponse: async def build_index(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid() sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid) sess = get_session(sid)
# Seed commander from query string when arriving from commander browser
q_commander = None
try:
q_commander = request.query_params.get("commander")
if q_commander:
# Persist a human-friendly commander name into session for the wizard
sess["commander"] = str(q_commander)
except Exception:
pass
return_url = None
try:
raw_return = request.query_params.get("return")
if raw_return:
parsed = urlparse(raw_return)
if not parsed.scheme and not parsed.netloc and parsed.path:
safe_path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}"
safe_return = safe_path
if parsed.query:
safe_return += f"?{parsed.query}"
if parsed.fragment:
safe_return += f"#{parsed.fragment}"
return_url = safe_return
except Exception:
return_url = None
if q_commander:
try:
log_commander_create_deck(
request,
commander=str(q_commander),
return_url=return_url,
)
except Exception:
pass
# Determine last step (fallback heuristics if not set) # Determine last step (fallback heuristics if not set)
last_step = sess.get("last_step") last_step = sess.get("last_step")
if not last_step: if not last_step:
@ -210,6 +245,7 @@ async def build_index(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"name": sess.get("custom_export_base"), "name": sess.get("custom_export_base"),
"last_step": last_step, "last_step": last_step,
"return_url": return_url,
}, },
) )
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")

View file

@ -0,0 +1,323 @@
from __future__ import annotations
from dataclasses import dataclass
from math import ceil
from typing import Iterable, Mapping, Sequence
from urllib.parse import urlencode
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from ..app import templates
from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog
from ..services.theme_catalog_loader import load_index, slugify
from ..services.telemetry import log_commander_page_view
router = APIRouter(prefix="/commanders", tags=["commanders"])
PAGE_SIZE = 20
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
_COLOR_NAMES: dict[str, str] = {
"W": "White",
"U": "Blue",
"B": "Black",
"R": "Red",
"G": "Green",
"C": "Colorless",
}
_TWO_COLOR_LABELS: dict[str, str] = {
"WU": "Azorius",
"UB": "Dimir",
"BR": "Rakdos",
"RG": "Gruul",
"WG": "Selesnya",
"WB": "Orzhov",
"UR": "Izzet",
"BG": "Golgari",
"WR": "Boros",
"UG": "Simic",
}
_THREE_COLOR_LABELS: dict[str, str] = {
"WUB": "Esper",
"UBR": "Grixis",
"BRG": "Jund",
"WRG": "Naya",
"WUG": "Bant",
"WBR": "Mardu",
"WUR": "Jeskai",
"UBG": "Sultai",
"URG": "Temur",
"WBG": "Abzan",
}
_FOUR_COLOR_LABELS: dict[str, str] = {
"WUBR": "Yore-Tiller",
"WUBG": "Witch-Maw",
"WURG": "Ink-Treader",
"WBRG": "Dune-Brood",
"UBRG": "Glint-Eye",
}
@dataclass(frozen=True, slots=True)
class CommanderTheme:
name: str
slug: str
summary: str | None
@dataclass(slots=True)
class CommanderView:
record: CommanderRecord
color_code: str
color_label: str
color_aria_label: str
themes: tuple[CommanderTheme, ...]
partner_summary: tuple[str, ...]
def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request", "").lower() == "true"
def _record_color_code(record: CommanderRecord) -> str:
code = record.color_identity_key or ""
if not code and record.is_colorless:
return "C"
return code
def _canon_color_code(raw: str | None) -> str:
if not raw:
return ""
text = raw.upper()
seen: set[str] = set()
ordered: list[str] = []
for color in _WUBRG_ORDER:
if color in text:
seen.add(color)
ordered.append(color)
if not ordered and "C" in text:
return "C"
return "".join(ordered)
def _color_label_from_code(code: str) -> str:
if not code:
return ""
if code == "C":
return "Colorless (C)"
if len(code) == 1:
base = _COLOR_NAMES.get(code, code)
return f"{base} ({code})"
if len(code) == 2:
label = _TWO_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if len(code) == 3:
label = _THREE_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if len(code) == 4:
label = _FOUR_COLOR_LABELS.get(code)
if label:
return f"{label} ({code})"
if code == "WUBRG":
return "Five-Color (WUBRG)"
parts = [_COLOR_NAMES.get(ch, ch) for ch in code]
pretty = " / ".join(parts)
return f"{pretty} ({code})"
def _color_aria_label(record: CommanderRecord) -> str:
if record.color_identity:
names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity]
return ", ".join(names)
return _COLOR_NAMES.get("C", "Colorless")
def _partner_summary(record: CommanderRecord) -> tuple[str, ...]:
parts: list[str] = []
if record.partner_with:
parts.append("Partner with " + ", ".join(record.partner_with))
elif record.is_partner:
parts.append("Partner available")
if record.supports_backgrounds:
parts.append("Choose a Background")
if record.is_background:
parts.append("Background commander")
return tuple(parts)
def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderTheme]) -> CommanderView:
theme_objs: list[CommanderTheme] = []
for theme_name in record.themes:
info = theme_info.get(theme_name)
if info is not None:
theme_objs.append(info)
else:
slug = slugify(theme_name)
theme_objs.append(CommanderTheme(name=theme_name, slug=slug, summary=None))
color_code = _record_color_code(record)
return CommanderView(
record=record,
color_code=color_code,
color_label=_color_label_from_code(color_code),
color_aria_label=_color_aria_label(record),
themes=tuple(theme_objs),
partner_summary=_partner_summary(record),
)
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: 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
return items
def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]:
present: set[str] = set()
for rec in records:
code = _record_color_code(rec)
if code:
present.add(code)
options: list[tuple[str, str]] = []
for mono in ("W", "U", "B", "R", "G", "C"):
if mono in present:
options.append((mono, _color_label_from_code(mono)))
combos = sorted((code for code in present if len(code) >= 2), key=lambda c: (len(c), c))
for code in combos:
options.append((code, _color_label_from_code(code)))
return options
def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, CommanderTheme]:
unique_names: set[str] = set()
for rec in records:
unique_names.update(rec.themes)
if not unique_names:
return {}
try:
idx = load_index()
except FileNotFoundError:
return {}
except Exception:
return {}
info: dict[str, CommanderTheme] = {}
for name in unique_names:
try:
slug = slugify(name)
except Exception:
slug = name
summary: str | None = None
try:
data = idx.summary_by_slug.get(slug)
if data:
summary = data.get("short_description") or data.get("description")
except Exception:
summary = None
info[name] = CommanderTheme(name=name, slug=slug, summary=summary)
return info
@router.get("/", response_class=HTMLResponse)
async def commanders_index(
request: Request,
q: str | None = Query(default=None, alias="q"),
color: str | None = Query(default=None, alias="color"),
page: int = Query(default=1, ge=1),
) -> HTMLResponse:
entries: Sequence[CommanderRecord] = ()
error: str | None = None
try:
catalog = load_commander_catalog()
entries = catalog.entries
except FileNotFoundError:
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
filtered = _filter_commanders(entries, q, color)
total_filtered = len(filtered)
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
if page > page_count:
page = page_count
start_index = (page - 1) * PAGE_SIZE
end_index = start_index + PAGE_SIZE
page_records = filtered[start_index:end_index]
theme_info = _build_theme_info(page_records)
views = [_record_to_view(rec, theme_info) for rec in page_records]
color_options = _build_color_options(entries) if entries else []
page_start = start_index + 1 if total_filtered else 0
page_end = start_index + len(page_records)
has_prev = page > 1
has_next = page < page_count
canon_color = _canon_color_code(color)
def _page_url(page_value: int) -> str:
params: dict[str, str] = {}
if q:
params["q"] = q
if canon_color:
params["color"] = canon_color
params["page"] = str(page_value)
return f"/commanders?{urlencode(params)}"
prev_page = page - 1 if has_prev else None
next_page = page + 1 if has_next else None
prev_url = _page_url(prev_page) if prev_page else None
next_url = _page_url(next_page) if next_page else None
current_path = request.url.path or "/commanders"
current_query = request.url.query or ""
if current_query:
return_url = f"{current_path}?{current_query}"
else:
return_url = current_path
context = {
"request": request,
"commanders": views,
"query": q or "",
"color": canon_color,
"color_options": color_options,
"total_count": len(entries),
"result_count": len(views),
"result_total": total_filtered,
"page": page,
"page_count": page_count,
"page_size": PAGE_SIZE,
"page_start": page_start,
"page_end": page_end,
"has_prev": has_prev,
"has_next": has_next,
"prev_page": prev_page,
"next_page": next_page,
"prev_url": prev_url,
"next_url": next_url,
"is_filtered": bool((q or "").strip() or (color or "").strip()),
"error": error,
"return_url": return_url,
}
template_name = "commanders/list_fragment.html" if _is_htmx(request) else "commanders/index.html"
try:
log_commander_page_view(
request,
page=page,
result_total=total_filtered,
result_count=len(views),
is_htmx=_is_htmx(request),
)
except Exception:
pass
return templates.TemplateResponse(template_name, context)

View file

@ -0,0 +1,423 @@
"""Commander catalog loader and normalization helpers for the web UI.
Responsibilities
================
- Read and normalize `commander_cards.csv` (shared with the deck builder).
- Produce deterministic commander records with rich metadata (slug, colors,
partner/background flags, theme tags, Scryfall image URLs).
- Cache the parsed catalog and invalidate on file timestamp changes.
The loader operates without pandas to keep the web layer light-weight and to
simplify unit testing. It honors the `CSV_FILES_DIR` environment variable via
`path_util.csv_dir()` just like the CLI builder.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
import ast
import csv
import os
import re
from urllib.parse import quote
from path_util import csv_dir
__all__ = [
"CommanderRecord",
"CommanderCatalog",
"load_commander_catalog",
"clear_commander_catalog_cache",
]
_COLOR_ALIAS = {
"W": "W",
"WHITE": "W",
"U": "U",
"BLUE": "U",
"B": "B",
"BLACK": "B",
"R": "R",
"RED": "R",
"G": "G",
"GREEN": "G",
"C": "C",
"COLORLESS": "C",
}
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G")
_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image"
@dataclass(frozen=True, slots=True)
class CommanderRecord:
"""Normalized commander row."""
name: str
face_name: str
display_name: str
slug: str
color_identity: Tuple[str, ...]
color_identity_key: str
is_colorless: bool
colors: Tuple[str, ...]
mana_cost: str
mana_value: Optional[float]
type_line: str
creature_types: Tuple[str, ...]
oracle_text: str
power: Optional[str]
toughness: Optional[str]
keywords: Tuple[str, ...]
themes: Tuple[str, ...]
theme_tokens: Tuple[str, ...]
edhrec_rank: Optional[int]
layout: str
side: Optional[str]
image_small_url: str
image_normal_url: str
partner_with: Tuple[str, ...]
is_partner: bool
supports_backgrounds: bool
is_background: bool
search_haystack: str
@dataclass(frozen=True, slots=True)
class CommanderCatalog:
"""Cached commander catalog with lookup helpers."""
source_path: Path
etag: str
mtime_ns: int
size: int
entries: Tuple[CommanderRecord, ...]
by_slug: Mapping[str, CommanderRecord]
def get(self, slug: str) -> Optional[CommanderRecord]:
return self.by_slug.get(slug)
_CACHE: Dict[str, CommanderCatalog] = {}
def clear_commander_catalog_cache() -> None:
"""Clear the in-memory commander catalog cache (testing/support)."""
_CACHE.clear()
def load_commander_catalog(
source_path: str | os.PathLike[str] | None = None,
*,
force_reload: bool = False,
) -> CommanderCatalog:
"""Load (and cache) the commander catalog.
Args:
source_path: Optional path to override the default csv (mostly for tests).
force_reload: When True, bypass cache even if the file is unchanged.
"""
csv_path = _resolve_commander_path(source_path)
key = str(csv_path)
if not force_reload:
cached = _CACHE.get(key)
if cached and _is_cache_valid(csv_path, cached):
return cached
catalog = _build_catalog(csv_path)
_CACHE[key] = catalog
return catalog
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _resolve_commander_path(source_path: str | os.PathLike[str] | None) -> Path:
if source_path is not None:
return Path(source_path).resolve()
return (Path(csv_dir()) / "commander_cards.csv").resolve()
def _is_cache_valid(path: Path, cached: CommanderCatalog) -> bool:
try:
stat_result = path.stat()
except FileNotFoundError:
return False
mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
if mtime_ns != cached.mtime_ns:
return False
return stat_result.st_size == cached.size
def _build_catalog(path: Path) -> CommanderCatalog:
if not path.exists():
raise FileNotFoundError(f"Commander CSV not found at {path}")
entries: List[CommanderRecord] = []
used_slugs: set[str] = set()
with path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
if reader.fieldnames is None:
raise ValueError("Commander CSV missing header row")
for index, row in enumerate(reader):
try:
record = _row_to_record(row, used_slugs)
except Exception:
continue
entries.append(record)
used_slugs.add(record.slug)
stat_result = path.stat()
mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
etag = f"{stat_result.st_size}-{mtime_ns}-{len(entries)}"
frozen_entries = tuple(entries)
by_slug = {record.slug: record for record in frozen_entries}
return CommanderCatalog(
source_path=path,
etag=etag,
mtime_ns=mtime_ns,
size=stat_result.st_size,
entries=frozen_entries,
by_slug=by_slug,
)
def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> CommanderRecord:
name = _clean_str(row.get("name")) or "Unknown Commander"
face_name = _clean_str(row.get("faceName"))
display_name = face_name or name
base_slug = _slugify(display_name)
side = _clean_str(row.get("side"))
if side and side.lower() not in {"", "a"}:
candidate = f"{base_slug}-{side.lower()}"
else:
candidate = base_slug
slug = _dedupe_slug(candidate, used_slugs)
color_identity, is_colorless = _parse_color_identity(row.get("colorIdentity"))
colors, _ = _parse_color_identity(row.get("colors"))
mana_cost = _clean_str(row.get("manaCost"))
mana_value = _parse_float(row.get("manaValue"))
type_line = _clean_str(row.get("type"))
creature_types = tuple(_parse_literal_list(row.get("creatureTypes")))
oracle_text = _clean_multiline(row.get("text"))
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")))
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"
partner_with = tuple(_extract_partner_with(oracle_text))
is_partner = bool(
partner_with
or _contains_keyword(oracle_text, "partner")
or _contains_keyword(oracle_text, "friends forever")
or _contains_keyword(oracle_text, "doctor's companion")
)
supports_backgrounds = _contains_keyword(oracle_text, "choose a background")
is_background = "background" in (type_line.lower() if type_line else "")
image_small_url = _build_scryfall_url(display_name, "small")
image_normal_url = _build_scryfall_url(display_name, "normal")
search_haystack = _build_haystack(display_name, type_line, themes, creature_types, keywords, oracle_text)
color_identity_key = "".join(color_identity) if color_identity else "C"
return CommanderRecord(
name=name,
face_name=face_name,
display_name=display_name,
slug=slug,
color_identity=color_identity,
color_identity_key=color_identity_key,
is_colorless=is_colorless,
colors=colors,
mana_cost=mana_cost,
mana_value=mana_value,
type_line=type_line,
creature_types=creature_types,
oracle_text=oracle_text,
power=power,
toughness=toughness,
keywords=keywords,
themes=themes,
theme_tokens=theme_tokens,
edhrec_rank=edhrec_rank,
layout=layout,
side=side or None,
image_small_url=image_small_url,
image_normal_url=image_normal_url,
partner_with=partner_with,
is_partner=is_partner,
supports_backgrounds=supports_backgrounds,
is_background=is_background,
search_haystack=search_haystack,
)
def _clean_str(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _clean_multiline(value: object) -> str:
if value is None:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
return "\n".join(line.rstrip() for line in text.split("\n"))
def _parse_float(value: object) -> Optional[float]:
text = _clean_str(value)
if not text:
return None
try:
return float(text)
except ValueError:
return None
def _parse_int(value: object) -> Optional[int]:
text = _clean_str(value)
if not text:
return None
try:
return int(float(text))
except ValueError:
return None
def _parse_literal_list(value: object) -> List[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(v).strip() for v in value if str(v).strip()]
text = str(value).strip()
if not text:
return []
try:
parsed = ast.literal_eval(text)
if isinstance(parsed, (list, tuple, set)):
return [str(v).strip() for v in parsed if str(v).strip()]
except Exception:
pass
parts = [part.strip() for part in text.replace(";", ",").split(",")]
return [part for part in parts if part]
def _split_to_list(value: object) -> List[str]:
text = _clean_str(value)
if not text:
return []
parts = [part.strip() for part in text.split(",")]
return [part for part in parts if part]
def _extract_partner_with(text: str) -> List[str]:
if not text:
return []
out: List[str] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
anchor = "Partner with "
if anchor not in line:
continue
after = line.split(anchor, 1)[1]
# Remove reminder text in parentheses and trailing punctuation.
target = after.split("(", 1)[0]
target = target.replace(" and ", ",")
for token in target.split(","):
cleaned = token.strip().strip(".")
if cleaned:
out.append(cleaned)
return out
def _contains_keyword(text: str, needle: str) -> bool:
if not text:
return False
return needle.lower() in text.lower()
def _parse_color_identity(value: object) -> Tuple[Tuple[str, ...], bool]:
text = _clean_str(value)
if not text:
return tuple(), True
tokens = re.split(r"[\s,&/]+", text)
colors: List[str] = []
colorless_flag = False
for token in tokens:
if not token:
continue
mapped = _COLOR_ALIAS.get(token.upper())
if mapped is None:
continue
if mapped == "C":
colorless_flag = True
else:
if mapped not in colors:
colors.append(mapped)
ordered = tuple(color for color in _WUBRG_ORDER if color in colors)
if ordered:
return ordered, False
return tuple(), True if colorless_flag or text.upper() in {"C", "COLORLESS"} else False
def _slugify(value: str) -> str:
normalized = value.lower().strip()
normalized = normalized.replace("+", " plus ")
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = re.sub(r"-+", "-", normalized).strip("-")
return normalized or "commander"
def _dedupe_slug(initial: str, existing: Iterable[str]) -> str:
base = initial or "commander"
if base not in existing:
return base
counter = 2
while f"{base}-{counter}" in existing:
counter += 1
return f"{base}-{counter}"
def _build_scryfall_url(name: str, version: str) -> str:
encoded = quote(name, safe="")
return f"{_SCYRFALL_BASE}&version={version}&exact={encoded}"
def _build_haystack(
display_name: str,
type_line: str,
themes: Tuple[str, ...],
creature_types: Tuple[str, ...],
keywords: Tuple[str, ...],
oracle_text: str,
) -> str:
tokens: List[str] = []
tokens.append(display_name.lower())
if type_line:
tokens.append(type_line.lower())
if themes:
tokens.extend(theme.lower() for theme in themes)
if creature_types:
tokens.extend(t.lower() for t in creature_types)
if keywords:
tokens.extend(k.lower() for k in keywords)
if oracle_text:
tokens.append(oracle_text.lower())
return "|".join(t for t in tokens if t)

View file

@ -0,0 +1,106 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict
from fastapi import Request
__all__ = [
"log_commander_page_view",
"log_commander_create_deck",
]
_LOGGER = logging.getLogger("web.commander_browser")
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
try:
logger.info(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
except Exception:
pass
def _request_id(request: Request) -> str | None:
try:
rid = getattr(request.state, "request_id", None)
if rid:
return str(rid)
except Exception:
return None
return None
def _client_ip(request: Request) -> str | None:
try:
client = getattr(request, "client", None)
if client and getattr(client, "host", None):
return str(client.host)
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
except Exception:
return None
return None
def _query_snapshot(request: Request) -> Dict[str, Any]:
snapshot: Dict[str, Any] = {}
try:
params = request.query_params
items = params.multi_items() if hasattr(params, "multi_items") else params.items()
for key, value in items:
key = str(key)
value = str(value)
if key in snapshot:
existing = snapshot[key]
if isinstance(existing, list):
existing.append(value)
else:
snapshot[key] = [existing, value]
else:
snapshot[key] = value
except Exception:
return {}
return snapshot
def log_commander_page_view(
request: Request,
*,
page: int,
result_total: int,
result_count: int,
is_htmx: bool,
) -> None:
payload: Dict[str, Any] = {
"event": "commander_browser.page_view",
"request_id": _request_id(request),
"path": str(request.url.path),
"query": _query_snapshot(request),
"page": int(page),
"result_total": int(result_total),
"result_count": int(result_count),
"is_htmx": bool(is_htmx),
"client_ip": _client_ip(request),
}
_emit(_LOGGER, payload)
def log_commander_create_deck(
request: Request,
*,
commander: str,
return_url: str | None,
) -> None:
payload: Dict[str, Any] = {
"event": "commander_browser.create_deck",
"request_id": _request_id(request),
"path": str(request.url.path),
"query": _query_snapshot(request),
"commander": commander,
"has_return": bool(return_url),
"return_url": return_url,
"client_ip": _client_ip(request),
}
_emit(_LOGGER, payload)

View file

@ -65,7 +65,7 @@
--blue-main: #1565c0; /* balanced blue */ --blue-main: #1565c0; /* balanced blue */
} }
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%; overflow-x:hidden; max-width:100vw;} html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;}
body { body {
font-family: system-ui, Arial, sans-serif; font-family: system-ui, Arial, sans-serif;
margin: 0; margin: 0;
@ -73,8 +73,10 @@ body {
background: var(--bg); background: var(--bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; height: 100%;
width: 100%; width: 100%;
overflow-x: hidden;
overflow-y: auto;
} }
/* Honor HTML hidden attribute across the app */ /* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; } [hidden] { display: none !important; }
@ -198,6 +200,15 @@ button:hover{ filter:brightness(1.05); }
.btn:hover{ filter:brightness(1.05); text-decoration:none; } .btn:hover{ filter:brightness(1.05); text-decoration:none; }
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } .btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
.color-identity{ display:inline-flex; align-items:center; gap:.35rem; }
.color-identity .mana + .mana{ margin-left:4px; }
.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; }
.mana-W{ background:#f9fafb; border-color:#d1d5db; }
.mana-U{ background:#3b82f6; border-color:#1d4ed8; }
.mana-B{ background:#111827; border-color:#1f2937; }
.mana-R{ background:#ef4444; border-color:#b91c1c; }
.mana-G{ background:#10b981; border-color:#047857; }
.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); } small, .muted{ color: var(--muted); }

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
<title>MTG Deckbuilder</title> <title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script> <script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<script> <script>
@ -81,6 +82,7 @@
<a href="/configs">Build from JSON</a> <a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %} {% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a> <a href="/owned">Owned Library</a>
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
<a href="/decks">Finished Decks</a> <a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a> <a href="/themes/">Themes</a>
{% if random_ui %}<a href="/random">Random</a>{% endif %} {% if random_ui %}<a href="/random">Random</a>{% endif %}
@ -172,6 +174,10 @@
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; } #hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; } #hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; } #hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
#hover-card-panel.hcp-simple { width:auto !important; max-width:min(360px, 90vw) !important; padding:12px !important; height:auto !important; max-height:none !important; overflow:hidden !important; }
#hover-card-panel.hcp-simple .hcp-body { display:flex; flex-direction:column; gap:12px; align-items:center; }
#hover-card-panel.hcp-simple .hcp-right { display:none !important; }
#hover-card-panel.hcp-simple .hcp-img { max-width:100%; }
/* Tag list as multi-column list instead of pill chips for readability */ /* Tag list as multi-column list instead of pill chips for readability */
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; } #hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; } #hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
@ -195,9 +201,9 @@
.list-row .dfc-toggle .icon { font-size:12px; } .list-row .dfc-toggle .icon { font-size:12px; }
.list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); } .list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); }
.list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); } .list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); }
#hover-card-panel.mobile { left:50% !important; top:auto !important; bottom:max(16px, 5vh); transform:translateX(-50%); width:min(92vw, 420px) !important; max-height:80vh; overflow-y:auto; padding:16px 18px; pointer-events:auto !important; } #hover-card-panel.mobile { left:50% !important; top:50% !important; bottom:auto !important; transform:translate(-50%, -50%); width:min(94vw, 460px) !important; max-height:88vh; overflow-y:auto; padding:20px 22px; pointer-events:auto !important; }
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:18px; } #hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:20px; }
#hover-card-panel.mobile .hcp-img { max-width:100%; margin:0 auto; } #hover-card-panel.mobile .hcp-img { width:100%; max-width:min(90vw, 420px) !important; margin:0 auto; }
#hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; } #hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; }
#hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; } #hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; } #hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
@ -923,12 +929,14 @@
var panel = ensurePanel(); var panel = ensurePanel();
if(!panel || panel.__hoverInit) return; if(!panel || panel.__hoverInit) return;
panel.__hoverInit = true; panel.__hoverInit = true;
var imgEl = panel.querySelector('.hcp-img'); var imgEl = panel.querySelector('.hcp-img');
var nameEl = panel.querySelector('.hcp-name'); var nameEl = panel.querySelector('.hcp-name');
var rarityEl = panel.querySelector('.hcp-rarity'); var rarityEl = panel.querySelector('.hcp-rarity');
var metaEl = panel.querySelector('.hcp-meta'); var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons'); var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags'); var tagsEl = panel.querySelector('.hcp-tags');
var bodyEl = panel.querySelector('.hcp-body');
var rightCol = panel.querySelector('.hcp-right');
var coarseQuery = window.matchMedia('(pointer: coarse)'); var coarseQuery = window.matchMedia('(pointer: coarse)');
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; } function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } } function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
@ -946,12 +954,11 @@
function positionPanel(evt){ function positionPanel(evt){
if(isMobileMode()){ if(isMobileMode()){
panel.classList.add('mobile'); panel.classList.add('mobile');
var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05)); panel.style.bottom = 'auto';
panel.style.bottom = bottomOffset + 'px';
panel.style.left = '50%'; panel.style.left = '50%';
panel.style.top = 'auto'; panel.style.top = '50%';
panel.style.right = 'auto'; panel.style.right = 'auto';
panel.style.transform = 'translateX(-50%)'; panel.style.transform = 'translate(-50%, -50%)';
panel.style.pointerEvents = 'auto'; panel.style.pointerEvents = 'auto';
} else { } else {
panel.classList.remove('mobile'); panel.classList.remove('mobile');
@ -990,6 +997,11 @@
if(!card) return; if(!card) return;
// Prefer attributes on container, fallback to child (image) if missing // Prefer attributes on container, fallback to child (image) if missing
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; } function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
var simpleSource = null;
if(card.closest){
simpleSource = card.closest('[data-hover-simple]');
}
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
var nm = attr('data-card-name') || attr('data-original-name') || 'Card'; var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
var rarity = (attr('data-rarity')||'').trim(); var rarity = (attr('data-rarity')||'').trim();
var mana = (attr('data-mana')||'').trim(); var mana = (attr('data-mana')||'').trim();
@ -1110,6 +1122,26 @@
} }
panel.classList.toggle('is-payoff', role === 'payoff'); panel.classList.toggle('is-payoff', role === 'payoff');
panel.classList.toggle('is-commander', isCommanderRole); panel.classList.toggle('is-commander', isCommanderRole);
var hasDetails = !forceSimple && (
!!roleLabel || !!mana || !!rarity || (reasonsRaw && reasonsRaw.trim()) || (overlapArr && overlapArr.length) || (allTags && allTags.length)
);
panel.classList.toggle('hcp-simple', !hasDetails);
if(rightCol){
rightCol.style.display = hasDetails ? 'flex' : 'none';
}
if(bodyEl){
if(!hasDetails){
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.alignItems = 'center';
bodyEl.style.gap = '12px';
} else {
bodyEl.style.display = '';
bodyEl.style.flexDirection = '';
bodyEl.style.alignItems = '';
bodyEl.style.gap = '';
}
}
var fuzzy = encodeURIComponent(nm); var fuzzy = encodeURIComponent(nm);
var rawName = nm || ''; var rawName = nm || '';
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1; var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;

View file

@ -2,12 +2,80 @@
{% block banner_subtitle %}Build a Deck{% endblock %} {% block banner_subtitle %}Build a Deck{% endblock %}
{% block content %} {% block content %}
<h2>Build a Deck</h2> <h2>Build a Deck</h2>
<div style="margin:.25rem 0 1rem 0;"> <div style="margin:.25rem 0 1rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button> <button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span> <span class="muted" style="margin-left:.25rem;">Quick-start wizard (name, commander, themes, ideals)</span>
{% if return_url %}
<a href="{{ return_url }}" class="btn" style="margin-left:auto;">← Back to Commanders</a>
{% endif %}
</div> </div>
<div id="wizard"> <div id="wizard">
<!-- Wizard content will load here after the modal submit starts the build. --> <!-- Wizard content will load here after the modal submit starts the build. -->
<noscript><p>Enable JavaScript to build a deck.</p></noscript> <noscript><p>Enable JavaScript to build a deck.</p></noscript>
</div> </div>
{% if commander %}
<span id="builder-init" data-commander="{{ commander|e }}" hidden></span>
<script>
(function(){
var opened = false;
function openWizard(){
if(opened) return; opened = true;
try{
var btn = document.querySelector('button.btn[hx-get="/build/new"]');
if(btn){ btn.click(); }
}catch(_){ }
}
// Pre-fill and auto-inspect when the modal content is injected
function onModalLoaded(e){
try{
var target = (e && e.detail && e.detail.target) ? e.detail.target : null; if(!target) return;
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
var init = document.getElementById('builder-init');
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
if(!preset) return;
var input = document.querySelector('input[name="commander"]');
if(input){
if(!input.value){ input.value = preset; }
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
try { input.focus(); } catch(_){ }
}
// If htmx is available, auto-load the inspect view for an exact preset name.
try {
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
// Also try to load multi-copy suggestions based on current radio defaults
setTimeout(function(){
try{
var mode = document.querySelector('input[name="tag_mode"]') || document.getElementById('modal_tag_mode');
var primary = document.getElementById('modal_primary_tag');
var secondary = document.getElementById('modal_secondary_tag');
var tertiary = document.getElementById('modal_tertiary_tag');
var params = new URLSearchParams();
params.set('commander', preset);
if (primary && primary.value) params.set('primary_tag', primary.value);
if (secondary && secondary.value) params.set('secondary_tag', secondary.value);
if (tertiary && tertiary.value) params.set('tertiary_tag', tertiary.value);
if (mode && mode.value) params.set('tag_mode', mode.value);
window.htmx.ajax('GET', '/build/new/multicopy?' + params.toString(), { target: '#newdeck-multicopy-slot', swap: 'innerHTML' });
}catch(_){ }
}, 250);
}
} catch(_){ }
}catch(_){ }
}
document.addEventListener('htmx:afterSwap', onModalLoaded);
// Open after DOM is ready; try a few hooks to ensure htmx is initialized
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(openWizard, 0);
} else {
document.addEventListener('DOMContentLoaded', function(){ setTimeout(openWizard, 0); });
}
if (window.htmx && typeof window.htmx.onLoad === 'function'){
window.htmx.onLoad(function(){ setTimeout(openWizard, 0); });
}
// Last resort: delayed attempt in case previous hooks raced htmx init
setTimeout(openWizard, 200);
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block content %}
<section class="commander-page">
<header class="commander-hero">
<h2>Commanders</h2>
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.</p>
</header>
<form
id="commander-filter-form"
class="commander-filters"
action="/commanders"
method="get"
hx-get="/commanders"
hx-target="#commander-results"
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search"
hx-include="#commander-filter-form"
hx-push-url="true"
hx-indicator="#commander-loading"
novalidate
>
<label>
<span class="filter-label">Search</span>
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commanders, themes, or text..." autocomplete="off" />
</label>
<label>
<span class="filter-label">Color identity</span>
<select id="commander-color" name="color">
<option value="">All colors</option>
{% for code, label in color_options %}
<option value="{{ code }}" {% if color == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<input type="hidden" name="page" value="{{ page }}" />
<button type="submit" class="btn filter-submit">Apply</button>
</form>
<div id="commander-loading" class="commander-loading" role="status" aria-live="polite">
<span class="sr-only">Loading commanders…</span>
<div class="commander-skeleton-list" aria-hidden="true">
{% for i in range(3) %}
<article class="commander-skeleton">
<div class="skeleton-thumb shimmer"></div>
<div class="skeleton-main">
<div class="skeleton-line skeleton-title shimmer"></div>
<div class="skeleton-line skeleton-meta shimmer"></div>
<div class="skeleton-chip-row">
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
</div>
<div class="skeleton-line skeleton-text shimmer"></div>
</div>
<div class="skeleton-cta shimmer"></div>
</article>
{% endfor %}
</div>
</div>
<div id="commander-results">
{% include "commanders/list_fragment.html" %}
</div>
</section>
<style>
.commander-page { display:flex; flex-direction:column; gap:1.25rem; }
.commander-hero h2 { margin:0; font-size:1.75rem; }
.commander-hero p { margin:0; max-width:60ch; }
.commander-filters { display:flex; flex-wrap:wrap; gap:.75rem 1rem; align-items:flex-end; }
.commander-filters label { display:flex; flex-direction:column; gap:.35rem; min-width:220px; }
.filter-label { font-size:.85rem; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
.commander-filters input,
.commander-filters select { background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:.45rem .6rem; min-height:2.4rem; }
.commander-filters input:focus,
.commander-filters select:focus { outline:2px solid var(--ring); outline-offset:2px; }
.filter-submit { height:2.4rem; align-self:flex-end; }
.commander-summary { font-size:.9rem; }
.commander-error { padding:.75rem .9rem; border:1px solid #f87171; background:rgba(248,113,113,.12); border-radius:10px; color:#fca5a5; }
.commander-empty { margin:1rem 0 0; }
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
.commander-thumb { width:160px; flex:0 0 auto; }
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
.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-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: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; }
.commander-partner-sep { opacity:.6; }
.commander-cta { margin-left:auto; display:flex; align-items:center; }
.commander-cta .btn { white-space:nowrap; }
.commander-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-top:1rem; flex-wrap:wrap; }
.commander-summary + .commander-pagination { margin-top:.75rem; }
.commander-pagination .pagination-group { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.commander-pagination .commander-page-btn { display:inline-flex; align-items:center; justify-content:center; min-width:96px; }
.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); }
.commander-loading { display:none; margin-top:1rem; }
.commander-loading.htmx-request { display:block; }
.commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; }
.commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
.skeleton-thumb { width:160px; height:220px; border-radius:10px; }
.skeleton-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; }
.skeleton-line { height:16px; border-radius:9999px; }
.skeleton-title { width:45%; height:22px; }
.skeleton-meta { width:30%; }
.skeleton-text { width:65%; }
.skeleton-chip-row { display:flex; gap:.5rem; flex-wrap:wrap; }
.skeleton-chip { width:90px; height:22px; border-radius:9999px; display:inline-block; }
.skeleton-cta { width:120px; height:42px; border-radius:9999px; }
.shimmer { background:linear-gradient(90deg, rgba(148,163,184,0.25) 25%, rgba(148,163,184,0.15) 37%, rgba(148,163,184,0.25) 63%); background-size:400% 100%; animation:commander-shimmer 1.4s ease-in-out infinite; }
@keyframes commander-shimmer {
0% { background-position:100% 0; }
100% { background-position:-100% 0; }
}
@media (max-width: 900px) {
.commander-row { flex-direction:column; }
.commander-thumb img { width:100%; max-width:280px; }
.commander-cta { margin-left:0; }
.commander-cta .btn { width:100%; justify-content:center; text-align:center; }
}
@media (max-width: 640px) {
.commander-filters { align-items:stretch; }
.filter-submit { width:100%; }
.commander-filters label { flex:1 1 100%; min-width:0; }
.commander-thumb { width:min(70vw, 220px); align-self:center; }
.commander-thumb img { width:100%; }
.skeleton-thumb { width:min(70vw, 220px); height:calc(min(70vw, 220px) * 1.4); }
}
</style>
<script>
(function(){
const form = document.getElementById('commander-filter-form');
if (!form) return;
const pageInput = form.querySelector('input[name="page"]');
if (!pageInput) return;
const resetPage = () => { pageInput.value = '1'; };
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 updatePageFromResults = (container) => {
if (!container) return;
const marker = container.querySelector('[data-current-page]');
if (marker) {
const current = marker.getAttribute('data-current-page');
if (current) pageInput.value = current;
}
};
document.body.addEventListener('htmx:afterSwap', (event) => {
const target = event.detail && event.detail.target;
if (!target || target.id !== 'commander-results') return;
updatePageFromResults(target);
// Intelligent scroll-to-top: only when triggered from bottom controls or when the summary/top controls are off-screen
const container = document.getElementById('commander-results');
const searchEl = document.getElementById('commander-search');
if (!container) 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
const rect = container.getBoundingClientRect();
const topInView = rect.top >= 0 && rect.top <= (window.innerHeight * 0.25);
// If we're below the top controls (content's top is above viewport) or the click came from the bottom controls,
// jump directly to the search input (no smooth animation) for fastest navigation.
if (fromBottom || rect.top < 0) {
requestAnimationFrame(() => {
if (searchEl) {
searchEl.scrollIntoView({ behavior: 'auto', block: 'start' });
try { searchEl.focus({ preventScroll: true }); } catch(_) { /* no-op */ }
} else {
window.scrollTo({ top: 0, behavior: 'auto' });
}
});
return;
}
if (!topInView) {
requestAnimationFrame(() => {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
});
updatePageFromResults(document.getElementById('commander-results'));
})();
</script>
{% endblock %}

View file

@ -0,0 +1,38 @@
<div class="commander-results-inner" data-current-page="{{ page }}">
{% if error %}
<div class="commander-error" role="alert">{{ error }}</div>
{% else %}
<div class="commander-summary muted">
{% if total_count %}
{% if commanders %}
Showing {{ page_start }}&nbsp;&ndash;&nbsp;{{ page_end }} of {{ result_total }} commander{% if result_total != 1 %}s{% endif %}{% if is_filtered %} (filtered){% endif %}.
{% else %}
No commanders matched your filters.
{% endif %}
{% else %}
No commander data available.
{% endif %}
</div>
{% if commanders %}
{% set pagination_position = 'top' %}
{% include "commanders/pagination_controls.html" %}
<div class="commander-list" role="list">
{% for entry in commanders %}
{% include "commanders/row_wireframe.html" %}
{% endfor %}
</div>
{% if page_count > 1 %}
{% set pagination_position = 'bottom' %}
{% include "commanders/pagination_controls.html" %}
{% endif %}
{% else %}
<p class="muted commander-empty" role="status">
{% if total_count %}
No commanders matched your filters.
{% else %}
Commander catalog is empty.
{% endif %}
</p>
{% endif %}
{% endif %}
</div>

View file

@ -0,0 +1,37 @@
<nav class="commander-pagination" role="navigation" aria-label="Commander pagination" {% if pagination_position == 'bottom' %}data-bottom-controls="1"{% endif %}>
<div class="pagination-group">
<a
class="btn ghost commander-page-btn {% if not has_prev %}disabled{% endif %}"
{% if has_prev %}
href="{{ prev_url }}"
hx-get="{{ prev_url }}"
hx-target="#commander-results"
hx-push-url="true"
data-scroll-top-on-swap="1"
{% else %}
aria-disabled="true"
tabindex="-1"
{% endif %}
>
&larr; Previous
</a>
<span class="commander-pagination-status" aria-live="polite">
Page {{ page }} of {{ page_count }}
</span>
<a
class="btn ghost commander-page-btn {% if not has_next %}disabled{% endif %}"
{% if has_next %}
href="{{ next_url }}"
hx-get="{{ next_url }}"
hx-target="#commander-results"
hx-push-url="true"
data-scroll-top-on-swap="1"
{% else %}
aria-disabled="true"
tabindex="-1"
{% endif %}
>
Next &rarr;
</a>
</div>
</nav>

View file

@ -0,0 +1,56 @@
{# Commander row partial fed by CommanderView entries #}
{% from "partials/_macros.html" import color_identity %}
{% set record = entry.record %}
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
<div class="commander-thumb">
{% set small = record.image_small_url or record.image_normal_url %}
<img
src="{{ small }}"
srcset="{{ small }} 160w, {{ record.image_normal_url or small }} 488w"
sizes="160px"
alt="{{ record.display_name }} card art"
loading="lazy"
decoding="async"
data-card-name="{{ record.display_name }}"
data-hover-simple="true"
/>
</div>
<div class="commander-main">
<div class="commander-header">
<h3 class="commander-name">{{ record.display_name }}</h3>
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
</div>
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>
{% if entry.themes %}
<div class="commander-themes" role="list">
{% for theme in entry.themes %}
{% set summary = theme.summary or 'Summary unavailable' %}
<button type="button"
class="commander-theme-chip"
role="listitem"
data-theme-name="{{ theme.name }}"
data-theme-slug="{{ theme.slug }}"
data-theme-summary="{{ summary }}"
title="{{ summary }}"
aria-label="{{ theme.name }} theme: {{ summary }}">
{{ theme.name }}
</button>
{% endfor %}
</div>
{% else %}
<div class="commander-themes commander-themes-empty">
<span class="muted">No themes linked yet.</span>
</div>
{% endif %}
{% if entry.partner_summary %}
<div class="commander-partners muted">
{% for note in entry.partner_summary %}
<span>{{ note }}</span>{% if not loop.last %}<span aria-hidden="true" class="commander-partner-sep"></span>{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="commander-cta">
<a class="btn" href="/build?commander={{ record.display_name|urlencode }}&return={{ return_url|urlencode }}" data-commander="{{ record.slug }}">Build</a>
</div>
</article>

View file

@ -66,6 +66,7 @@
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') + 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') + ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') + ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0') + ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0') + ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '') + ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')

View file

@ -4,12 +4,14 @@
<div class="actions-grid"> <div class="actions-grid">
<a class="action-button primary" href="/build">Build a Deck</a> <a class="action-button primary" href="/build">Build a Deck</a>
<a class="action-button" href="/configs">Run a JSON Config</a> <a class="action-button" href="/configs">Run a JSON Config</a>
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %} {% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
<a class="action-button" href="/owned">Owned Library</a> <a class="action-button" href="/owned">Owned Library</a>
{% if show_commanders %}<a class="action-button" href="/commanders">Browse Commanders</a>{% endif %}
<a class="action-button" href="/decks">Finished Decks</a> <a class="action-button" href="/decks">Finished Decks</a>
<a class="action-button" href="/themes/">Browse Themes</a> <a class="action-button" href="/themes/">Browse Themes</a>
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %} {% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %} {% if show_diagnostics %}<a class="action-button" href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
</div> </div>
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);"> <div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
<span id="themes-quick-status">Themes: …</span> <span id="themes-quick-status">Themes: …</span>

View file

@ -11,3 +11,19 @@
{{ '🔒 Unlock' if locked else '🔓 Lock' }} {{ '🔒 Unlock' if locked else '🔓 Lock' }}
</button> </button>
{%- endmacro %} {%- endmacro %}
{% macro color_identity(colors, is_colorless=False, aria_label='', title_text='') -%}
<div class="color-identity" role="img"
aria-label="{{ aria_label }}"
data-colorless="{{ '1' if is_colorless or not (colors and colors|length) else '0' }}"
{% if title_text %}title="{{ title_text }}"{% endif %}>
{% if colors and colors|length %}
{% for color in colors %}
<span class="mana mana-{{ color }}" aria-hidden="true"></span>
{% endfor %}
{% else %}
<span class="mana mana-C" aria-hidden="true"></span>
{% endif %}
<span class="sr-only">{{ aria_label }}</span>
</div>
{%- endmacro %}

View file

@ -1,3 +1,5 @@
name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side
Krenko, Mob Boss,,1200,R,R,{2}{R}{R},4,Legendary Creature,['Goblin'],Tap: Create X 1/1 red Goblin tokens.,3,3,,['Goblin Kindred'],normal, "Krenko, Mob Boss","Krenko, Mob Boss",1200,R,R,{2}{R}{R},4,"Legendary Creature — Goblin","['Goblin']","Tap: Create X 1/1 red Goblin tokens.",3,3,,"['Goblin Kindred']",normal,
Isamaru, Hound of Konda,,2500,W,W,{W},1,Legendary Creature,['Hound'],Legendary creature.,2,2,,['Dog Kindred'],normal, "Isamaru, Hound of Konda","Isamaru, Hound of Konda",2500,W,W,{W},1,"Legendary Creature — Hound","['Hound']","Legendary creature.",2,2,,"['Dog Kindred']",normal,
"Traxos, Scourge of Kroog","Traxos, Scourge of Kroog",3500,Colorless,,{4},4,"Legendary Artifact Creature — Construct","['Construct']","Trample\nTraxos enters the battlefield tapped and doesn't untap during your untap step.\nWhenever you cast a historic spell, untap Traxos.",7,7,Trample,"['Artifacts Matter']",normal,
"Atraxa, Praetors' Voice","Atraxa, Praetors' Voice",150,"B, G, U, W","B, G, U, W",{1}{G}{W}{U}{B},4,"Legendary Creature — Phyrexian Angel Horror","['Angel', 'Horror', 'Phyrexian']","Flying, vigilance, deathtouch, lifelink\nAt the beginning of your end step, proliferate.",4,4,"Deathtouch, Flying, Lifelink, Vigilance","['Counters Matter', 'Proliferate']",normal,

1 name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side name faceName edhrecRank colorIdentity colors manaCost manaValue type creatureTypes text power toughness keywords themeTags layout side
2 Krenko, Mob Boss,,1200,R,R,{2}{R}{R},4,Legendary Creature,['Goblin'],Tap: Create X 1/1 red Goblin tokens.,3,3,,['Goblin Kindred'],normal, Krenko, Mob Boss Krenko, Mob Boss 1200 R R {2}{R}{R} 4 Legendary Creature — Goblin ['Goblin'] Tap: Create X 1/1 red Goblin tokens. 3 3 ['Goblin Kindred'] normal
3 Isamaru, Hound of Konda,,2500,W,W,{W},1,Legendary Creature,['Hound'],Legendary creature.,2,2,,['Dog Kindred'],normal, Isamaru, Hound of Konda Isamaru, Hound of Konda 2500 W W {W} 1 Legendary Creature — Hound ['Hound'] Legendary creature. 2 2 ['Dog Kindred'] normal
4 Traxos, Scourge of Kroog Traxos, Scourge of Kroog 3500 Colorless {4} 4 Legendary Artifact Creature — Construct ['Construct'] Trample\nTraxos enters the battlefield tapped and doesn't untap during your untap step.\nWhenever you cast a historic spell, untap Traxos. 7 7 Trample ['Artifacts Matter'] normal
5 Atraxa, Praetors' Voice Atraxa, Praetors' Voice 150 B, G, U, W B, G, U, W {1}{G}{W}{U}{B} 4 Legendary Creature — Phyrexian Angel Horror ['Angel', 'Horror', 'Phyrexian'] Flying, vigilance, deathtouch, lifelink\nAt the beginning of your end step, proliferate. 4 4 Deathtouch, Flying, Lifelink, Vigilance ['Counters Matter', 'Proliferate'] normal

72
docs/commander_catalog.md Normal file
View file

@ -0,0 +1,72 @@
# Commander Catalog Onboarding
The Commander Browser and deck builder both read from `csv_files/commander_cards.csv`. This file is generated during setup and must stay in sync with the fields the web UI expects. Use this guide whenever you need to add a new commander, refresh the dataset, or troubleshoot missing entries.
## Where the file lives
- Default path: `csv_files/commander_cards.csv`
- Override: set `CSV_FILES_DIR` (env var) before launching the app; the loader resolves `commander_cards.csv` inside that directory.
- Caching: the web layer caches the parsed file in process. Restart the app or call `clear_commander_catalog_cache()` in a shell if you edit the CSV while the server is running.
## Required columns
The loader normalizes these columns; keep the header names exact. Optional fields can be blank but should still be present.
| Column | Notes |
| --- | --- |
| `name` | Printed front name. Used as the fallback display label.
| `faceName` | Front face name for MDFCs/split cards. Defaults to `name` when empty.
| `side` | Leave blank or `A` for the primary face. Secondary faces become distinct slugs.
| `colorIdentity` | WUBRG characters (any casing). `C` marks colorless identities.
| `colors` | Printed colors; mainly used for ordering badges.
| `manaCost` | Optional but keeps rows sortable in the UI.
| `manaValue` | Numeric converted mana cost.
| `type` | Full type line (e.g., `Legendary Creature — Phyrexian Angel`).
| `creatureTypes` | Python/JSON list or comma-separated string of creature subtypes.
| `text` | Oracle text. Enables partner/background detection and hover tooltips.
| `power` / `toughness` | Optional stats. Leave blank for non-creatures.
| `keywords` | Comma-separated keywords (Flying, Vigilance, …).
| `themeTags` | Python/JSON list of curated themes (e.g., `['Angels', 'Life Gain']`).
| `edhrecRank` | Optional EDHREC popularity rank (integer).
| `layout` | Layout string from MTGJSON (`normal`, `modal_dfc`, etc.).
Additional columns are preserved but ignored by the browser; feel free to keep upstream metadata.
## Recommended refresh workflow
1. Ensure dependencies are installed: `pip install -r requirements.txt`.
2. Regenerate the commander CSV using the setup module:
```powershell
python -c "from file_setup.setup import regenerate_csvs_all; regenerate_csvs_all()"
```
This downloads the latest MTGJSON card dump (if needed), reapplies commander eligibility rules, and rewrites `commander_cards.csv`.
3. (Optional) If you only need a fresh commander list and already have up-to-date `cards.csv`, run:
```powershell
python -c "from file_setup.setup import determine_commanders; determine_commanders()"
```
4. Restart the web server (or your desktop app) so the cache reloads the new file.
5. Validate with the targeted test:
```powershell
python -m pytest -q code/tests/test_commander_catalog_loader.py
```
The test confirms required columns exist, normalization still works, and caching invalidates correctly.
## Manual edits (quick fixes)
If you need to hotfix a single row before a full regeneration:
1. Open the CSV in a UTF-8 aware editor (Excel can re-save with a UTF-8 BOM — prefer a text editor when possible).
2. Add or edit the row, ensuring the slug-worthy fields (`name`, `faceName`, `side`) are unique.
3. Keep the `themeTags` value as a Python/JSON list (e.g., `['Artifacts']`), or a comma-delimited list without stray quotes.
4. Save the file and restart the server so the cache refreshes.
5. Backfill the curated themes in `config/themes/` if the new commander should surface dedicated tags.
> Manual edits are acceptable for emergency fixes but commit regenerated data as soon as possible so automation stays trustworthy.
## Troubleshooting
- **`Commander catalog is unavailable` error**: The app could not find the CSV. Verify the file exists under `CSV_FILES_DIR` and has a header row.
- **Row missing in the browser**: Ensure the commander passed eligibility (legendary rules) and the rows `layout`/`side` data is correct. Slug collisions are auto-deduped (`-2`, `-3`, …) but rely on unique `name`+`side` combos.
- **Theme chips absent**: Confirm `themeTags` contains at least one value and that the theme slug exists in the theme catalog; otherwise the UI hides the chips.
For deeper issues, enable verbose logs with `SHOW_LOGS=1` before restarting the web process.