feat(web): refine commander search and theme UX

This commit is contained in:
matt 2025-10-01 10:54:32 -07:00
parent fad6ceb13b
commit 0448419d9f
12 changed files with 764 additions and 116 deletions

View file

@ -25,6 +25,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Commander hover preview collapses to a card-only view when browsing commanders, and all theme chips display without the previous “+ more” overflow badge.
- Added a Content Security Policy upgrade directive so proxied HTTPS deployments safely rewrite commander pagination requests to HTTPS, preventing mixed-content blocks.
- Commander thumbnails use a fixed-width 160px frame (scaling down on small screens) to eliminate inconsistent image sizing across the catalog.
- Commander browser search now separates commander name and theme inputs, introduces fuzzy theme suggestions, and tightens commander name matching to near-exact results.
- Commander browser no longer auto-scrolls when typing in search fields, keeping focus anchored near the filters.
- Commander theme chips feature larger typography, multi-line wrapping, and a mobile-friendly tap dialog for reading summaries.
- Theme dialog now prefers full editorial descriptions, so longer summaries display completely on mobile.
- Commander theme labels now unescape leading punctuation (e.g., +2/+2 Counters) to avoid stray backslashes in the UI.
- Theme summary dialog now opens when clicking theme chips on desktop as well as mobile.
- Commander list pagination controls now appear above and below the results and automatically scroll to the top when switching pages for quicker navigation.
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
- Preview performance CI check now waits for `/healthz` and retries theme catalog pagination fetches to dodge transient 500s during cold starts.

View file

@ -18,6 +18,12 @@
- Commander hover preview shows card-only panel in browser context and removes the “+ more” overflow badge from theme chips.
- Content Security Policy upgrade directive ensures HTMX pagination requests remain HTTPS-safe behind proxies.
- Commander thumbnails adopt a fixed-width 160px frame (responsive on small screens) for consistent layout.
- Commander browser now separates name vs theme search, adds fuzzy theme suggestions, and tightens commander name matching to near-exact results.
- Commander search results stay put while filtering; typing no longer auto-scrolls the page away from the filter controls.
- Commander theme chips are larger, wrap cleanly, and display an accessible summary dialog when tapped on mobile.
- Theme dialogs now surface the full editorial description when available, improving longer summaries on small screens.
- Commander theme names unescape leading punctuation (e.g., +2/+2 Counters) so labels render without stray backslashes.
- Theme summary dialog also opens on desktop clicks, giving parity with mobile behavior.
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
- Preview performance CI check now waits for service health and retries catalog pagination fetches to smooth out transient 500s on cold boots.

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import csv
import json
import time
from pathlib import Path
@ -69,3 +71,61 @@ def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytes
updated = loader.load_commander_catalog()
assert updated is not first
assert "zada-hedron-grinder" in updated.by_slug
def test_commander_theme_labels_unescape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
custom_dir = tmp_path / "csv_custom"
custom_dir.mkdir()
csv_path = custom_dir / "commander_cards.csv"
with csv_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(
[
"name",
"faceName",
"edhrecRank",
"colorIdentity",
"colors",
"manaCost",
"manaValue",
"type",
"creatureTypes",
"text",
"power",
"toughness",
"keywords",
"themeTags",
"layout",
"side",
]
)
theme_value = json.dumps([r"\+2/\+2 Counters", "+1/+1 Counters"])
writer.writerow(
[
"Escape Tester",
"Escape Tester",
"1234",
"R",
"R",
"{3}{R}",
"4",
"Legendary Creature — Archer",
"['Archer']",
"Test",
"2",
"2",
"",
theme_value,
"normal",
"",
]
)
_set_csv_dir(monkeypatch, custom_dir)
catalog = loader.load_commander_catalog()
assert len(catalog.entries) == 1
record = catalog.entries[0]
assert record.themes == ("+2/+2 Counters", "+1/+1 Counters")
assert "+2/+2 counters" in record.theme_tokens

View file

@ -76,7 +76,15 @@ def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> N
search_haystack=f"{name.lower()}"
)
records.append(record)
fake_catalog = SimpleNamespace(entries=tuple(records))
_install_custom_catalog(monkeypatch, records)
def _install_custom_catalog(monkeypatch: pytest.MonkeyPatch, records: list) -> None:
fake_catalog = SimpleNamespace(
entries=tuple(records),
by_slug={record.slug: record for record in records},
)
def loader() -> SimpleNamespace:
return fake_catalog
@ -120,13 +128,7 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp
themes=themes,
theme_tokens=tuple(theme.lower() for theme in themes),
)
fake_catalog = SimpleNamespace(entries=(enriched,))
def loader() -> SimpleNamespace:
return fake_catalog
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
_install_custom_catalog(monkeypatch, [enriched])
response = client.get("/commanders")
assert response.status_code == 200
@ -135,3 +137,155 @@ def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeyp
assert "commander-theme-chip-more" not in body # no overflow badge rendered
for name in themes:
assert name in body
def _commander_fixture(sample, *, name: str, slug: str, themes: tuple[str, ...] = ()):
return replace(
sample,
name=name,
face_name=name,
display_name=name,
slug=slug,
themes=themes,
theme_tokens=tuple(theme.lower() for theme in themes),
search_haystack="|".join([name.lower(), *[theme.lower() for theme in themes]]),
)
def test_commanders_search_ignores_theme_tokens(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
target = _commander_fixture(
sample,
name="Avatar Aang // Aang, Master of Elements",
slug="avatar-aang",
themes=("Elemental", "Avatar"),
)
other = _commander_fixture(
sample,
name="Generic Guardian",
slug="generic-guardian",
themes=("Avatar", "Guardian"),
)
_install_custom_catalog(monkeypatch, [target, other])
response = client.get("/commanders", params={"q": "Avatar Aang"})
assert response.status_code == 200
body = response.text
assert 'data-commander-slug="avatar-aang"' in body
assert 'data-commander-slug="generic-guardian"' not in body
def test_commanders_search_supports_token_reordering(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
target = _commander_fixture(
sample,
name="Avatar Aang // Aang, Master of Elements",
slug="avatar-aang",
themes=("Elemental",),
)
fallback = _commander_fixture(
sample,
name="Master of Avatar Arts",
slug="master-of-avatar-arts",
themes=("Avatar",),
)
_install_custom_catalog(monkeypatch, [target, fallback])
response = client.get("/commanders", params={"q": "Aang Avatar"})
assert response.status_code == 200
body = response.text
assert 'data-commander-slug="avatar-aang"' in body
assert 'data-commander-slug="master-of-avatar-arts"' not in body
def test_commanders_theme_search_filters(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
aggro_commander = _commander_fixture(
sample,
name="Aggro Ace",
slug="aggro-ace",
themes=("Aggro", "Combat"),
)
control_commander = _commander_fixture(
sample,
name="Control Keeper",
slug="control-keeper",
themes=("Control", "Value"),
)
_install_custom_catalog(monkeypatch, [aggro_commander, control_commander])
response = client.get("/commanders", params={"theme": "Aggo"})
assert response.status_code == 200
body = response.text
assert 'data-commander-slug="aggro-ace"' in body
assert 'data-commander-slug="control-keeper"' not in body
assert 'data-theme-suggestion="Aggro"' in body
assert 'id="theme-suggestions"' in body
assert 'option value="Aggro"' in body
def test_commanders_theme_recommendations_render_in_fragment(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
aggro_commander = _commander_fixture(
sample,
name="Aggro Ace",
slug="aggro-ace",
themes=("Aggro", "Combat"),
)
control_commander = _commander_fixture(
sample,
name="Control Keeper",
slug="control-keeper",
themes=("Control", "Value"),
)
_install_custom_catalog(monkeypatch, [aggro_commander, control_commander])
response = client.get(
"/commanders",
params={"theme": "Aggo"},
headers={"HX-Request": "true"},
)
assert response.status_code == 200
body = response.text
assert 'data-theme-suggestion="Aggro"' in body
assert 'data-commander-slug="aggro-ace"' in body
def test_commander_name_fuzzy_tightened(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
catalog = load_commander_catalog()
sample = catalog.entries[0]
finneas = _commander_fixture(
sample,
name="Finneas, Ace Archer",
slug="finneas-ace-archer",
themes=("Aggro", "Counters"),
)
torgal = _commander_fixture(
sample,
name="Torgal, A Fine Hound",
slug="torgal-a-fine-hound",
themes=("Aggro", "Combat"),
)
gorbag = _commander_fixture(
sample,
name="Gorbag of Minas Morgul",
slug="gorbag-of-minas-morgul",
themes=("Aggro", "Treasure"),
)
_install_custom_catalog(monkeypatch, [finneas, torgal, gorbag])
response = client.get("/commanders", params={"q": "Finneas"})
assert response.status_code == 200
body = response.text
assert 'data-commander-slug="finneas-ace-archer"' in body
assert 'data-commander-slug="torgal-a-fine-hound"' not in body
assert 'data-commander-slug="gorbag-of-minas-morgul"' not in body

View file

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

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from difflib import SequenceMatcher
from math import ceil
from typing import Iterable, Mapping, Sequence
from urllib.parse import urlencode
import re
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
@ -16,6 +18,11 @@ from ..services.telemetry import log_commander_page_view
router = APIRouter(prefix="/commanders", tags=["commanders"])
PAGE_SIZE = 20
_THEME_MATCH_THRESHOLD = 0.52
_THEME_RECOMMENDATION_FLOOR = 0.35
_THEME_RECOMMENDATION_LIMIT = 6
_MIN_NAME_MATCH_SCORE = 0.8
_WORD_PATTERN = re.compile(r"[a-z0-9]+")
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
_COLOR_NAMES: dict[str, str] = {
@ -76,6 +83,12 @@ class CommanderView:
partner_summary: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class ThemeRecommendation:
name: str
score: float
def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request", "").lower() == "true"
@ -169,22 +182,203 @@ def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderT
)
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None) -> list[CommanderRecord]:
def _normalize_search_text(value: str | None) -> str:
if not value:
return ""
tokens = _WORD_PATTERN.findall(value.lower())
if not tokens:
return ""
return " ".join(tokens)
def _commander_name_candidates(record: CommanderRecord) -> tuple[str, ...]:
seen: set[str] = set()
candidates: list[str] = []
for raw in (record.display_name, record.face_name, record.name):
normalized = _normalize_search_text(raw)
if not normalized:
continue
if normalized not in seen:
seen.add(normalized)
candidates.append(normalized)
return tuple(candidates)
def _partial_ratio(a: str, b: str) -> float:
if not a or not b:
return 0.0
shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
length = len(shorter)
if length == 0:
return 0.0
best = 0.0
window_count = len(longer) - length + 1
for start in range(max(1, window_count)):
end = start + length
if end > len(longer):
segment = longer[-length:]
else:
segment = longer[start:end]
score = SequenceMatcher(None, shorter, segment).ratio()
if score > best:
best = score
if best >= 0.99:
break
return best
def _token_scores(query_tokens: tuple[str, ...], candidate_tokens: tuple[str, ...]) -> tuple[float, float]:
if not query_tokens or not candidate_tokens:
return 0.0, 0.0
totals: list[float] = []
for token in query_tokens:
best = 0.0
for candidate in candidate_tokens:
score = SequenceMatcher(None, token, candidate).ratio()
if score > best:
best = score
if best >= 0.99:
break
totals.append(best)
average = sum(totals) / len(totals) if totals else 0.0
minimum = min(totals) if totals else 0.0
return average, minimum
def _commander_name_match_score(query: str, record: CommanderRecord) -> float:
normalized_query = _normalize_search_text(query)
if not normalized_query:
return 0.0
query_tokens = tuple(normalized_query.split())
best_score = 0.0
for candidate in _commander_name_candidates(record):
candidate_tokens = tuple(candidate.split())
base_score = SequenceMatcher(None, normalized_query, candidate).ratio()
partial = _partial_ratio(normalized_query, candidate)
token_average, token_minimum = _token_scores(query_tokens, candidate_tokens)
substring_bonus = 0.0
if candidate.startswith(normalized_query):
substring_bonus = 1.0
elif query_tokens and all(token in candidate_tokens for token in query_tokens):
substring_bonus = 0.92
elif normalized_query in candidate:
substring_bonus = 0.88
elif query_tokens and all(token in candidate for token in query_tokens):
substring_bonus = 0.8
elif query_tokens and any(token in candidate for token in query_tokens):
substring_bonus = 0.65
score = max(base_score, partial, token_average, substring_bonus)
if query_tokens and token_minimum < 0.45 and not candidate.startswith(normalized_query) and normalized_query not in candidate:
score = min(score, token_minimum)
if score > best_score:
best_score = score
if best_score >= 0.999:
break
return best_score
def _collect_theme_names(records: Sequence[CommanderRecord]) -> tuple[str, ...]:
seen: set[str] = set()
ordered: list[str] = []
for rec in records:
for theme_name in rec.themes:
if not theme_name:
continue
if theme_name not in seen:
seen.add(theme_name)
ordered.append(theme_name)
ordered.sort(key=lambda name: name.lower())
return tuple(ordered)
def _theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], candidate: str) -> float:
normalized_candidate = _normalize_search_text(candidate)
if not normalized_candidate:
return 0.0
candidate_tokens = tuple(normalized_candidate.split())
base_score = SequenceMatcher(None, normalized_query, normalized_candidate).ratio()
partial = _partial_ratio(normalized_query, normalized_candidate)
token_average, token_minimum = _token_scores(query_tokens, candidate_tokens)
substring_bonus = 0.0
if normalized_candidate.startswith(normalized_query):
substring_bonus = 1.0
elif normalized_query in normalized_candidate:
substring_bonus = 0.9
elif query_tokens and all(token in candidate_tokens for token in query_tokens):
substring_bonus = 0.85
elif query_tokens and any(token in candidate_tokens for token in query_tokens):
substring_bonus = 0.7
score = max(base_score, partial, token_average, substring_bonus)
if query_tokens and token_minimum < 0.4 and not normalized_candidate.startswith(normalized_query) and normalized_query not in normalized_candidate:
score = min(score, max(token_minimum, 0.0))
return score
def _best_theme_match_score(normalized_query: str, query_tokens: tuple[str, ...], record: CommanderRecord) -> float:
best = 0.0
for theme_name in record.themes:
score = _theme_match_score(normalized_query, query_tokens, theme_name)
if score > best:
best = score
if best >= 0.999:
break
return best
def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[str]) -> tuple[ThemeRecommendation, ...]:
normalized_query = _normalize_search_text(theme_query)
if not normalized_query:
return tuple()
query_tokens = tuple(normalized_query.split())
scored: list[ThemeRecommendation] = []
for name in theme_names:
score = _theme_match_score(normalized_query, query_tokens, name)
if score <= 0.0:
continue
scored.append(ThemeRecommendation(name=name, score=score))
if not scored:
return tuple()
scored.sort(key=lambda item: (-item.score, item.name.lower()))
filtered = [item for item in scored if item.score >= _THEME_RECOMMENDATION_FLOOR]
if not filtered:
filtered = scored
return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT])
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]:
items = list(records)
color_code = _canon_color_code(color)
if color_code:
items = [rec for rec in items if _record_color_code(rec) == color_code]
if q:
lowered = q.lower().strip()
if lowered:
tokens = [tok for tok in lowered.split() if tok]
if tokens:
filtered: list[CommanderRecord] = []
for rec in items:
haystack = rec.search_haystack or ""
if all(tok in haystack for tok in tokens):
filtered.append(rec)
items = filtered
normalized_query = _normalize_search_text(q)
if normalized_query:
filtered: list[tuple[float, CommanderRecord]] = []
for rec in items:
score = _commander_name_match_score(normalized_query, rec)
if score >= _MIN_NAME_MATCH_SCORE:
filtered.append((score, rec))
if filtered:
filtered.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower()))
items = [rec for _, rec in filtered]
else:
items = []
normalized_theme_query = _normalize_search_text(theme)
if normalized_theme_query and items:
theme_tokens = tuple(normalized_theme_query.split())
filtered_by_theme: list[tuple[float, CommanderRecord]] = []
for rec in items:
score = _best_theme_match_score(normalized_theme_query, theme_tokens, rec)
if score >= _THEME_MATCH_THRESHOLD:
filtered_by_theme.append((score, rec))
if filtered_by_theme:
filtered_by_theme.sort(key=lambda pair: (-pair[0], pair[1].display_name.lower()))
items = [rec for _, rec in filtered_by_theme]
else:
items = []
return items
@ -226,7 +420,11 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
try:
data = idx.summary_by_slug.get(slug)
if data:
summary = data.get("short_description") or data.get("description")
description = data.get("description") if isinstance(data, dict) else None
short_description = data.get("short_description") if isinstance(data, dict) else None
summary = description or short_description
if (summary is None or not summary.strip()) and short_description:
summary = short_description
except Exception:
summary = None
info[name] = CommanderTheme(name=name, slug=slug, summary=summary)
@ -237,6 +435,7 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander
async def commanders_index(
request: Request,
q: str | None = Query(default=None, alias="q"),
theme: str | None = Query(default=None, alias="theme"),
color: str | None = Query(default=None, alias="color"),
page: int = Query(default=1, ge=1),
) -> HTMLResponse:
@ -247,7 +446,10 @@ async def commanders_index(
entries = catalog.entries
except FileNotFoundError:
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
filtered = _filter_commanders(entries, q, color)
theme_names = _collect_theme_names(entries)
theme_query = (theme or "").strip()
filtered = _filter_commanders(entries, q, color, theme_query)
theme_recommendations = _build_theme_recommendations(theme_query, theme_names)
total_filtered = len(filtered)
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
if page > page_count:
@ -268,6 +470,8 @@ async def commanders_index(
params: dict[str, str] = {}
if q:
params["q"] = q
if theme_query:
params["theme"] = theme_query
if canon_color:
params["color"] = canon_color
params["page"] = str(page_value)
@ -289,8 +493,11 @@ async def commanders_index(
"request": request,
"commanders": views,
"query": q or "",
"theme_query": theme_query,
"color": canon_color,
"color_options": color_options,
"theme_options": theme_names,
"theme_recommendations": theme_recommendations,
"total_count": len(entries),
"result_count": len(views),
"result_total": total_filtered,
@ -305,7 +512,7 @@ async def commanders_index(
"next_page": next_page,
"prev_url": prev_url,
"next_url": next_url,
"is_filtered": bool((q or "").strip() or (color or "").strip()),
"is_filtered": bool((q or "").strip() or (color or "").strip() or theme_query),
"error": error,
"return_url": return_url,
}

View file

@ -49,6 +49,7 @@ _COLOR_ALIAS = {
}
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G")
_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image"
_THEME_ESCAPE_PATTERN = re.compile(r"\\([+/\\-])")
@dataclass(frozen=True, slots=True)
@ -214,7 +215,8 @@ def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> Comm
power = _clean_str(row.get("power")) or None
toughness = _clean_str(row.get("toughness")) or None
keywords = tuple(_split_to_list(row.get("keywords")))
themes = tuple(_parse_literal_list(row.get("themeTags")))
raw_themes = _parse_literal_list(row.get("themeTags"))
themes = tuple(filter(None, (_clean_theme_label(theme) for theme in raw_themes)))
theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
edhrec_rank = _parse_int(row.get("edhrecRank"))
layout = _clean_str(row.get("layout")) or "normal"
@ -317,6 +319,14 @@ def _parse_literal_list(value: object) -> List[str]:
return [part for part in parts if part]
def _clean_theme_label(value: str) -> str:
text = _clean_str(value)
if not text:
return ""
text = text.replace("\ufeff", "")
return _THEME_ESCAPE_PATTERN.sub(r"\1", text)
def _split_to_list(value: object) -> List[str]:
text = _clean_str(value)
if not text:

View file

@ -13,16 +13,25 @@
method="get"
hx-get="/commanders"
hx-target="#commander-results"
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search"
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search, keyup changed delay:300ms from:#commander-theme"
hx-include="#commander-filter-form"
hx-push-url="true"
hx-indicator="#commander-loading"
novalidate
>
<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" />
<span class="filter-label">Commander name</span>
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commander names..." autocomplete="off" />
</label>
<label>
<span class="filter-label">Theme</span>
<input type="search" id="commander-theme" name="theme" value="{{ theme_query }}" placeholder="Search themes..." list="theme-suggestions" autocomplete="off" />
</label>
<datalist id="theme-suggestions">
{% for name in theme_options[:200] %}
<option value="{{ name }}"></option>
{% endfor %}
</datalist>
<label>
<span class="filter-label">Color identity</span>
<select id="commander-color" name="color">
@ -89,9 +98,9 @@
.commander-name { margin:0; font-size:1.25rem; }
.color-identity { display:flex; align-items:center; gap:.35rem; }
.commander-context { margin:0; font-size:.95rem; }
.commander-themes { display:flex; flex-wrap:wrap; gap:.4rem; }
.commander-themes { display:flex; flex-wrap:wrap; gap:.45rem; width:100%; }
.commander-themes-empty { font-size:.85rem; }
.commander-theme-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; appearance:none; font:inherit; }
.commander-theme-chip { display:inline-flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:.2rem; padding:4px 12px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.85rem; line-height:1.3; font-weight:600; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .18s ease, border-color .18s ease, transform .18s ease; appearance:none; font-family:inherit; white-space:normal; text-align:center; min-width:0; max-width:min(100%, 24ch); word-break:break-word; overflow-wrap: anywhere; }
.commander-theme-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
.commander-theme-chip:hover { background:rgba(148,163,184,.25); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
.commander-partners { display:flex; flex-wrap:wrap; gap:.4rem; font-size:.85rem; }
@ -105,6 +114,12 @@
.commander-pagination .commander-page-btn[disabled],
.commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; }
.commander-pagination-status { font-size:.85rem; color:var(--muted); }
.theme-recommendations { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem 1rem; margin-top:.75rem; }
.theme-recommendations-label { font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
.theme-recommendations-chips { display:flex; flex-wrap:wrap; gap:.4rem; }
.theme-suggestion-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(94,106,136,.18); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; }
.theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
.theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
.commander-loading { display:none; margin-top:1rem; }
.commander-loading.htmx-request { display:block; }
@ -124,6 +139,17 @@
0% { background-position:100% 0; }
100% { background-position:-100% 0; }
}
.commander-theme-dialog { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:1.5rem; background:rgba(15,23,42,.55); backdrop-filter:blur(6px); z-index:1300; }
.commander-theme-dialog[data-open="true"] { display:flex; }
.commander-theme-dialog__panel { background:var(--panel); color:var(--text); border-radius:16px; width:min(92vw, 420px); border:1px solid rgba(148,163,184,.35); box-shadow:0 24px 60px rgba(15,23,42,.42); padding:1.5rem; display:flex; flex-direction:column; gap:1rem; }
.commander-theme-dialog__title { margin:0; font-size:1.45rem; line-height:1.2; }
.commander-theme-dialog__body { margin:0; font-size:1.2rem; line-height:1.65; white-space:normal; word-break:break-word; }
.commander-theme-dialog__close { align-self:flex-end; min-width:0; }
@media (max-width: 640px) {
.commander-theme-dialog__panel { width:min(94vw, 360px); padding:1.25rem; }
.commander-theme-dialog__title { font-size:1.3rem; }
.commander-theme-dialog__body { font-size:1.1rem; }
}
@media (max-width: 900px) {
.commander-row { flex-direction:column; }
@ -148,10 +174,106 @@
if (!pageInput) return;
const resetPage = () => { pageInput.value = '1'; };
const setLastTrigger = (value) => { form.dataset.lastTrigger = value; };
const searchField = document.getElementById('commander-search');
const colorField = document.getElementById('commander-color');
if (searchField) searchField.addEventListener('input', resetPage);
if (colorField) colorField.addEventListener('change', resetPage);
const themeField = document.getElementById('commander-theme');
if (searchField) {
searchField.addEventListener('input', () => {
resetPage();
setLastTrigger('search');
});
}
if (colorField) {
colorField.addEventListener('change', () => {
resetPage();
setLastTrigger('color');
});
}
if (themeField) {
themeField.addEventListener('input', () => {
resetPage();
setLastTrigger('theme');
});
}
form.addEventListener('submit', () => {
if (!form.dataset.lastTrigger) {
setLastTrigger('submit');
}
});
const coarseQuery = window.matchMedia('(pointer: coarse)');
const prefersThemeModal = () => (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768;
let themeDialog;
let themeDialogTitle;
let themeDialogBody;
let themeDialogClose;
function closeThemeDialog() {
if (!themeDialog || themeDialog.dataset.open !== 'true') return;
themeDialog.dataset.open = 'false';
themeDialog.setAttribute('aria-hidden', 'true');
const invoker = themeDialog.__lastInvoker;
themeDialog.__lastInvoker = null;
if (invoker && typeof invoker.focus === 'function') {
try {
invoker.focus({ preventScroll: true });
} catch (_) {}
}
}
const ensureThemeDialog = () => {
if (themeDialog) return themeDialog;
themeDialog = document.getElementById('commander-theme-dialog');
if (!themeDialog) {
themeDialog = document.createElement('div');
themeDialog.id = 'commander-theme-dialog';
themeDialog.className = 'commander-theme-dialog';
themeDialog.setAttribute('aria-hidden', 'true');
themeDialog.innerHTML = `
<div class="commander-theme-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="commander-theme-dialog-title">
<h3 class="commander-theme-dialog__title" id="commander-theme-dialog-title"></h3>
<p class="commander-theme-dialog__body"></p>
<button type="button" class="btn commander-theme-dialog__close">Close</button>
</div>
`;
document.body.appendChild(themeDialog);
}
themeDialogTitle = themeDialog.querySelector('.commander-theme-dialog__title');
themeDialogBody = themeDialog.querySelector('.commander-theme-dialog__body');
themeDialogClose = themeDialog.querySelector('.commander-theme-dialog__close');
if (themeDialogClose && !themeDialogClose.__bound) {
themeDialogClose.__bound = true;
themeDialogClose.addEventListener('click', () => closeThemeDialog());
}
if (!themeDialog.__backdropBound) {
themeDialog.__backdropBound = true;
themeDialog.addEventListener('click', (evt) => {
if (evt.target === themeDialog) {
closeThemeDialog();
}
});
}
return themeDialog;
};
const openThemeDialog = (name, summary, invoker) => {
ensureThemeDialog();
if (!themeDialog) return;
themeDialog.setAttribute('aria-hidden', 'false');
themeDialog.dataset.open = 'true';
themeDialog.__lastInvoker = invoker || null;
if (themeDialogTitle) themeDialogTitle.textContent = name || 'Theme';
if (themeDialogBody) themeDialogBody.textContent = summary && summary.trim() ? summary : 'Summary unavailable.';
requestAnimationFrame(() => {
if (themeDialogClose) {
try {
themeDialogClose.focus({ preventScroll: true });
} catch (_) {}
}
});
};
const updatePageFromResults = (container) => {
if (!container) return;
@ -170,6 +292,11 @@
const container = document.getElementById('commander-results');
const searchEl = document.getElementById('commander-search');
if (!container) return;
const lastTrigger = form.dataset.lastTrigger || '';
form.dataset.lastTrigger = '';
if (lastTrigger === 'search' || lastTrigger === 'theme') {
return;
}
const invoker = event.detail && event.detail.elt ? event.detail.elt : null;
const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]');
// If not from bottom, check whether the top of the results is already within view; if so, skip scroll
@ -196,6 +323,39 @@
});
updatePageFromResults(document.getElementById('commander-results'));
document.body.addEventListener('click', (event) => {
const suggestion = event.target && event.target.closest ? event.target.closest('[data-theme-suggestion]') : null;
if (suggestion) {
if (!themeField) return;
event.preventDefault();
const value = suggestion.getAttribute('data-theme-suggestion') || '';
themeField.value = value;
resetPage();
setLastTrigger('theme');
themeField.dispatchEvent(new Event('input', { bubbles: true }));
try {
form.requestSubmit();
} catch (_) {
form.submit();
}
return;
}
const chip = event.target && event.target.closest ? event.target.closest('.commander-theme-chip') : null;
if (chip) {
event.preventDefault();
const name = chip.getAttribute('data-theme-name') || chip.textContent.trim();
const summary = chip.getAttribute('data-theme-summary') || 'Summary unavailable.';
openThemeDialog(name, summary, chip);
return;
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeThemeDialog();
}
});
})();
</script>
{% endblock %}

View file

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

View file

@ -25,14 +25,17 @@
<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"
<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 }}">
title="{{ summary }}"
aria-label="{{ theme.name }} theme: {{ summary }}"
data-hover-simple-target="theme"
data-hover-simple-name="{{ theme.name }}"
data-hover-simple-summary="{{ summary }}">
{{ theme.name }}
</button>
{% endfor %}

View file

@ -39,23 +39,19 @@ services:
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5"
# ------------------------------------------------------------------
# Random Build (Alpha) Feature Flags
# RANDOM_MODES: backend enablement (seeded selection endpoints)
# RANDOM_UI: enable Surprise/Reroll controls in UI
# RANDOM_MAX_ATTEMPTS: safety cap on retries for constraints
# RANDOM_TIMEOUT_MS: per-attempt timeout (ms) before giving up
# Random Mode Feature Flags
# ------------------------------------------------------------------
# Random Modes (feature flags)
RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
# RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1

View file

@ -5,24 +5,29 @@ services:
image: mwisnowski/mtg-python-deckbuilder:latest
container_name: mtg-deckbuilder-web
ports:
- "8080:8080" # Host:Container — open http://localhost:8080
- "8080:8080"
environment:
PYTHONUNBUFFERED: "1"
TERM: "xterm-256color"
DEBIAN_FRONTEND: "noninteractive"
# ------------------------------------------------------------------
# Core UI Feature Toggles
# (Enable/disable visibility of sections; most default to off in code)
# ------------------------------------------------------------------
# UI features/flags
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
ENABLE_PRESETS: "0" # 1=show presets section
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable
WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics
# Sampling experiments (optional)
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
# Sampling experiments
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
# Rarity weighting (advanced; default weights tuned for variety)
@ -36,81 +41,106 @@ services:
# RARITY_DIVERSITY_OVER_PENALTY: "-0.5"
# ------------------------------------------------------------------
# Random Build (Alpha) Feature Flags
# Random Mode Feature Flags
# ------------------------------------------------------------------
RANDOM_MODES: "0" # 1=backend random build endpoints
RANDOM_UI: "0" # 1=UI Surprise/Reroll controls
RANDOM_MAX_ATTEMPTS: "5" # Retry cap for constrained random builds
RANDOM_TIMEOUT_MS: "5000" # Per-attempt timeout (ms)
# Random Modes (feature flags)
RANDOM_MODES: "1" # 1=enable random build endpoints and backend features
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
RANDOM_TERTIARY_THEME: "" # optional tertiary theme slug override
RANDOM_AUTO_FILL: "0" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL: "1" # when 1, auto-fill missing secondary/tertiary themes
RANDOM_AUTO_FILL_SECONDARY: "" # override secondary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_AUTO_FILL_TERTIARY: "" # override tertiary auto-fill (blank inherits from RANDOM_AUTO_FILL)
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches when 1
RANDOM_STRICT_THEME_MATCH: "0" # require strict theme matches for commanders when 1
RANDOM_CONSTRAINTS: "" # inline JSON constraints for random builds (optional)
RANDOM_CONSTRAINTS_PATH: "" # path to JSON constraints file (takes precedence)
RANDOM_SEED: "" # deterministic random seed (int or string)
RANDOM_OUTPUT_JSON: "" # path or directory for random build payload export
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
# Theming
THEME: "system" # system|light|dark default theme
THEME: "dark" # system|light|dark
# ------------------------------------------------------------------
# Setup / Tagging / Catalog
# Setup / Tagging / Catalog Controls
# WEB_AUTO_SETUP: auto-run initial tagging & theme generation when needed
# WEB_AUTO_REFRESH_DAYS: refresh card data if older than N days (0=never)
# WEB_TAG_PARALLEL + WEB_TAG_WORKERS: parallel tag extraction
# THEME_CATALOG_MODE: merge (Phase B) | legacy | build | phaseb (merge synonyms)
# WEB_AUTO_ENFORCE: 1=run bracket/legal compliance auto-export JSON after builds
# WEB_CUSTOM_EXPORT_BASE: override export path base (optional)
# APP_VERSION: surfaced in UI/health endpoints
# ------------------------------------------------------------------
WEB_AUTO_SETUP: "1" # Auto-run setup/tagging on demand
WEB_AUTO_REFRESH_DAYS: "7" # Refresh card data if stale (days; 0=never)
WEB_TAG_PARALLEL: "1" # Parallel tag extraction on
WEB_TAG_WORKERS: "4" # Worker count (CPU bound; tune as needed)
THEME_CATALOG_MODE: "merge" # Phase B merged theme builder
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping YAML export on fast path (default 0 = always export)
# Setup/Tagging performance
WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
THEME_CATALOG_MODE: "merge" # Use merged Phase B catalog builder (with YAML export)
THEME_YAML_FAST_SKIP: "0" # 1=allow skipping per-theme YAML on fast path (rare; default always export)
# Live YAML scan interval in seconds for change detection (dev convenience)
# THEME_CATALOG_YAML_SCAN_INTERVAL_SEC: "2.0"
# Prewarm common theme filters at startup (speeds first interactions)
# WEB_THEME_FILTER_PREWARM: "0"
WEB_AUTO_ENFORCE: "0" # 1=auto compliance JSON export after builds
WEB_CUSTOM_EXPORT_BASE: "" # Optional export base override
APP_VERSION: "v2.3.2" # Displayed in footer/health
WEB_AUTO_ENFORCE: "0" # 1=auto-run compliance export after builds
WEB_CUSTOM_EXPORT_BASE: "" # Optional: custom base dir for deck export artifacts
APP_VERSION: "2.3.2" # Displayed version label (set per release/tag)
# ------------------------------------------------------------------
# Misc Land Selection Tuning (Step 7)
# Misc / Land Selection (Step 7) Environment Tuning
# Uncomment to fine-tune utility land heuristics. Theme weighting allows
# matching candidate lands to selected themes for bias.
# ------------------------------------------------------------------
# MISC_LAND_DEBUG: "1" # Write debug CSVs (diagnostics only)
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75"
# MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0"
# MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Fallback if MIN/MAX unset
# MISC_LAND_THEME_MATCH_BASE: "1.4"
# MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15"
# MISC_LAND_THEME_MATCH_CAP: "2.0"
# Misc land tuning (utility land selection Step 7)
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1
# MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (01). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build
# MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (01) for dynamic EDHREC keep range
# MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (used only if MIN/MAX not both provided)
# (Optional theme weighting overrides)
# MISC_LAND_THEME_MATCH_BASE: "1.4" # Multiplier if at least one theme tag matches
# MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # Increment per extra matching tag beyond first
# MISC_LAND_THEME_MATCH_CAP: "2.0" # Cap for total theme multiplier
# ------------------------------------------------------------------
# Path Overrides
# Deck Export / Directory Overrides (headless & web browsing paths)
# DECK_EXPORTS / DECK_CONFIG: override mount points inside container
# OWNED_CARDS_DIR / CARD_LIBRARY_DIR: inventory upload path (alias preserved)
# ------------------------------------------------------------------
# DECK_EXPORTS: "/app/deck_files"
# DECK_CONFIG: "/app/config"
# OWNED_CARDS_DIR: "/app/owned_cards"
# CARD_LIBRARY_DIR: "/app/owned_cards" # legacy alias
# Paths (optional overrides)
# DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports
# DECK_CONFIG: "/app/config" # Where the config browser looks for *.json
# OWNED_CARDS_DIR: "/app/owned_cards" # Preferred path for owned inventory uploads
# CARD_LIBRARY_DIR: "/app/owned_cards" # Back-compat alias for OWNED_CARDS_DIR
# CSV base directory override (useful for testing with frozen snapshots)
# CSV_FILES_DIR: "/app/csv_files"
# Inject a one-off synthetic CSV for index testing without altering shards
# CARD_INDEX_EXTRA_CSV: ""
# ------------------------------------------------------------------
# Headless / CLI Mode (optional automation)
# Headless / Non-interactive Build Configuration
# Provide commander or tag indices/names; toggles for which phases to include
# Counts optionally tune land/fetch/ramp/etc targets.
# ------------------------------------------------------------------
# DECK_MODE: "headless"
# HEADLESS_EXPORT_JSON: "1"
# DECK_COMMANDER: ""
# DECK_PRIMARY_CHOICE: "1"
# DECK_SECONDARY_CHOICE: ""
# DECK_TERTIARY_CHOICE: ""
# DECK_PRIMARY_TAG: ""
# Headless-only settings
# DECK_MODE: "headless" # Auto-run headless flow in CLI mode
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
# DECK_COMMANDER: "" # Commander name query
# DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based)
# DECK_SECONDARY_CHOICE: "" # Optional secondary index
# DECK_TERTIARY_CHOICE: "" # Optional tertiary index
# DECK_PRIMARY_TAG: "" # Or tag names instead of indices
# DECK_SECONDARY_TAG: ""
# DECK_TERTIARY_TAG: ""
# DECK_BRACKET_LEVEL: "3"
# DECK_BRACKET_LEVEL: "3" # 15
# DECK_ADD_LANDS: "1"
# DECK_ADD_CREATURES: "1"
# DECK_ADD_NON_CREATURE_SPELLS: "1"
@ -123,32 +153,37 @@ services:
# DECK_DUAL_COUNT: ""
# DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_COUNT: ""
# DECK_TAG_MODE: "AND"
# DECK_TAG_MODE: "AND" # AND|OR (if supported)
# HEADLESS_RANDOM_MODE: "0" # 1=force headless random mode instead of scripted build
# Entrypoint knobs (only if you change the entrypoint behavior)
# APP_MODE: "web" # web|cli — selects uvicorn vs CLI
# HOST: "0.0.0.0" # Uvicorn bind host
# PORT: "8080" # Uvicorn port
# WORKERS: "1" # Uvicorn workers
# (HOST/PORT honored by entrypoint; WORKERS for multi-worker uvicorn if desired)
# ------------------------------------------------------------------
# Entrypoint / Server knobs
# Testing / Diagnostics Specific (rarely changed in compose)
# SHOW_MISC_POOL: "1" # (already above) expose misc pool debug UI if implemented
# ------------------------------------------------------------------
# APP_MODE: "web" # web|cli
# HOST: "0.0.0.0" # Bind host
# PORT: "8080" # Uvicorn port
# WORKERS: "1" # Uvicorn workers
# ------------------------------------------------------------------
# Editorial / Theme Catalog Controls (advanced / optional)
# These are primarily for maintainers refining automated theme
# descriptions & popularity analytics. Leave commented for normal use.
# Editorial / Theme Catalog Controls
# These drive automated description generation, popularity bucketing,
# YAML backfilling, and regression / metrics exports. Normally only
# used during catalog curation or CI.
# ------------------------------------------------------------------
# EDITORIAL_SEED: "1234" # Deterministic seed for reproducible ordering.
# EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes.
# EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket thresholds (4 ints).
# EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json.
# EDITORIAL_BACKFILL_YAML: "0" # 1=write description/popularity back to YAML (missing only).
# EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include fallback description usage summary in JSON.
# EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint) 1=fail if any theme lacks description.
# EDITORIAL_REQUIRE_POPULARITY: "0" # (lint) 1=fail if any theme lacks popularity bucket.
# EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated examples target.
# EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) enforce above threshold vs warn.
# EDITORIAL_SEED: "1234" # Deterministic seed for description & inference ordering.
# EDITORIAL_AGGRESSIVE_FILL: "0" # 1=borrow extra synergies for sparse themes (<2 curated/enforced).
# EDITORIAL_POP_BOUNDARIES: "50,120,250,600" # Override popularity bucket boundaries (4 comma ints).
# EDITORIAL_POP_EXPORT: "0" # 1=emit theme_popularity_metrics.json alongside theme_list.json.
# EDITORIAL_BACKFILL_YAML: "0" # 1=enable YAML metadata backfill (description/popularity) on build.
# EDITORIAL_INCLUDE_FALLBACK_SUMMARY: "0" # 1=include description_fallback_summary block in JSON output.
# EDITORIAL_REQUIRE_DESCRIPTION: "0" # (lint script) 1=fail if a theme lacks description.
# EDITORIAL_REQUIRE_POPULARITY: "0" # (lint script) 1=fail if a theme lacks popularity bucket.
# EDITORIAL_MIN_EXAMPLES: "0" # (future) minimum curated example commanders/cards (guard rails).
# EDITORIAL_MIN_EXAMPLES_ENFORCE: "0" # (future) 1=enforce above threshold; else warn only.
# ------------------------------------------------------------------
# Theme Preview Cache & Redis (optional)
@ -171,11 +206,12 @@ services:
# THEME_PREVIEW_TTL_STEPS: "2,4,2,3,1" # step counts for band progression
# Redis backend (optional)
# THEME_PREVIEW_REDIS_URL: "redis://redis:6379/0"
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
# THEME_PREVIEW_REDIS_DISABLE: "0" # 1=force disable redis even if URL is set
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files
- ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards
restart: unless-stopped
working_dir: /app
restart: "no"