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

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_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
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)
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
@ -231,6 +232,7 @@ templates.env.globals.update({
"show_logs": SHOW_LOGS,
"show_setup": SHOW_SETUP,
"show_diagnostics": SHOW_DIAGNOSTICS,
"show_commanders": SHOW_COMMANDERS,
"virtualize": SHOW_VIRTUALIZE,
"enable_themes": ENABLE_THEMES,
"enable_pwa": ENABLE_PWA,
@ -815,6 +817,7 @@ async def status_sys():
"flags": {
"SHOW_LOGS": bool(SHOW_LOGS),
"SHOW_SETUP": bool(SHOW_SETUP),
"SHOW_COMMANDERS": bool(SHOW_COMMANDERS),
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
"ENABLE_THEMES": bool(ENABLE_THEMES),
"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 owned as owned_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(config_routes.router)
app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
app.include_router(owned_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
try:
@ -2190,6 +2195,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, 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 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.telemetry import log_commander_create_deck
from urllib.parse import urlparse
# Cache for available card names used by validation endpoints
_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:
sid = request.cookies.get("sid") or new_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)
last_step = sess.get("last_step")
if not last_step:
@ -210,6 +245,7 @@ async def build_index(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"name": sess.get("custom_export_base"),
"last_step": last_step,
"return_url": return_url,
},
)
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 */
}
*{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 {
font-family: system-ui, Arial, sans-serif;
margin: 0;
@ -73,8 +73,10 @@ body {
background: var(--bg);
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
}
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
@ -198,6 +200,15 @@ button:hover{ filter:brightness(1.05); }
.btn:hover{ filter:brightness(1.05); text-decoration: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; }
.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; }
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
<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>
@ -81,6 +82,7 @@
<a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
<a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a>
{% 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-img-wrap { grid-column:1 / 2; }
#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 */
#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; }
@ -195,9 +201,9 @@
.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='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 .hcp-body { display:flex; flex-direction:column; gap:18px; }
#hover-card-panel.mobile .hcp-img { max-width:100%; margin:0 auto; }
#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:20px; }
#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-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
@ -923,12 +929,14 @@
var panel = ensurePanel();
if(!panel || panel.__hoverInit) return;
panel.__hoverInit = true;
var imgEl = panel.querySelector('.hcp-img');
var nameEl = panel.querySelector('.hcp-name');
var rarityEl = panel.querySelector('.hcp-rarity');
var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags');
var imgEl = panel.querySelector('.hcp-img');
var nameEl = panel.querySelector('.hcp-name');
var rarityEl = panel.querySelector('.hcp-rarity');
var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags');
var bodyEl = panel.querySelector('.hcp-body');
var rightCol = panel.querySelector('.hcp-right');
var coarseQuery = window.matchMedia('(pointer: coarse)');
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
@ -946,12 +954,11 @@
function positionPanel(evt){
if(isMobileMode()){
panel.classList.add('mobile');
var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05));
panel.style.bottom = bottomOffset + 'px';
panel.style.bottom = 'auto';
panel.style.left = '50%';
panel.style.top = 'auto';
panel.style.top = '50%';
panel.style.right = 'auto';
panel.style.transform = 'translateX(-50%)';
panel.style.transform = 'translate(-50%, -50%)';
panel.style.pointerEvents = 'auto';
} else {
panel.classList.remove('mobile');
@ -990,6 +997,11 @@
if(!card) return;
// 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)) || ''; }
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 rarity = (attr('data-rarity')||'').trim();
var mana = (attr('data-mana')||'').trim();
@ -1110,6 +1122,26 @@
}
panel.classList.toggle('is-payoff', role === 'payoff');
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 rawName = nm || '';
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 content %}
<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>
<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 id="wizard">
<!-- Wizard content will load here after the modal submit starts the build. -->
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
</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 %}

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_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')

View file

@ -4,12 +4,14 @@
<div class="actions-grid">
<a class="action-button primary" href="/build">Build a Deck</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>
{% 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="/themes/">Browse Themes</a>
{% 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 %}
<a class="action-button" href="/themes/">Browse Themes</a>
{% if random_ui %}<a class="action-button" href="/random">Random Build</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 id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
<span id="themes-quick-status">Themes: …</span>

View file

@ -11,3 +11,19 @@
{{ '🔒 Unlock' if locked else '🔓 Lock' }}
</button>
{%- 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 %}