overhaul: migrated to tailwind css for css management, consolidated custom css, removed inline css, removed unneeded css, and otherwise improved page styling

This commit is contained in:
matt 2025-10-28 08:21:52 -07:00
parent f1e21873e7
commit b994978f60
81 changed files with 15784 additions and 2936 deletions

View file

@ -1,22 +1,18 @@
"""Loader for background cards derived from `background_cards.csv`."""
"""Loader for background cards derived from all_cards.parquet."""
from __future__ import annotations
import ast
import csv
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import re
from typing import Mapping, Tuple
from typing import Any, Mapping, Tuple
from logging_util import get_logger
from deck_builder.partner_background_utils import analyze_partner_background
from path_util import csv_dir
LOGGER = get_logger(__name__)
BACKGROUND_FILENAME = "background_cards.csv"
@dataclass(frozen=True, slots=True)
class BackgroundCard:
@ -57,7 +53,7 @@ class BackgroundCatalog:
def load_background_cards(
source_path: str | Path | None = None,
) -> BackgroundCatalog:
"""Load and cache background card data."""
"""Load and cache background card data from all_cards.parquet."""
resolved = _resolve_background_path(source_path)
try:
@ -65,7 +61,7 @@ def load_background_cards(
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
size = stat.st_size
except FileNotFoundError:
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None
raise FileNotFoundError(f"Background data not found at {resolved}") from None
entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
etag = f"{size}-{mtime_ns}-{len(entries)}"
@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B
if not path.exists():
return tuple(), "unknown"
with path.open("r", encoding="utf-8", newline="") as handle:
first_line = handle.readline()
version = "unknown"
if first_line.startswith("#"):
version = _parse_version(first_line)
else:
handle.seek(0)
reader = csv.DictReader(handle)
if reader.fieldnames is None:
return tuple(), version
entries = _rows_to_cards(reader)
try:
import pandas as pd
df = pd.read_parquet(path, engine="pyarrow")
# Filter for background cards
if 'isBackground' not in df.columns:
LOGGER.warning("isBackground column not found in %s", path)
return tuple(), "unknown"
df_backgrounds = df[df['isBackground']].copy()
if len(df_backgrounds) == 0:
LOGGER.warning("No background cards found in %s", path)
return tuple(), "unknown"
entries = _rows_to_cards(df_backgrounds)
version = "parquet"
except Exception as e:
LOGGER.error("Failed to load backgrounds from %s: %s", path, e)
return tuple(), "unknown"
frozen = tuple(entries)
return frozen, version
def _resolve_background_path(override: str | Path | None) -> Path:
"""Resolve path to all_cards.parquet."""
if override:
return Path(override).resolve()
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve()
# Use card_files/processed/all_cards.parquet
return Path("card_files/processed/all_cards.parquet").resolve()
def _parse_version(line: str) -> str:
tokens = line.lstrip("# ").strip().split()
for token in tokens:
if "=" not in token:
continue
key, value = token.split("=", 1)
if key == "version":
return value
return "unknown"
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
def _rows_to_cards(df) -> list[BackgroundCard]:
"""Convert DataFrame rows to BackgroundCard objects."""
entries: list[BackgroundCard] = []
seen: set[str] = set()
for raw in reader:
if not raw:
for _, row in df.iterrows():
if row.empty:
continue
card = _row_to_card(raw)
card = _row_to_card(row)
if card is None:
continue
key = card.display_name.lower()
@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
continue
seen.add(key)
entries.append(card)
entries.sort(key=lambda card: card.display_name)
return entries
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
name = _clean_str(row.get("name"))
face_name = _clean_str(row.get("faceName")) or None
def _row_to_card(row) -> BackgroundCard | None:
"""Convert a DataFrame row to a BackgroundCard."""
# Helper to safely get values from DataFrame row
def get_val(key: str):
try:
if hasattr(row, key):
val = getattr(row, key)
# Handle pandas NA/None
if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__):
return None
return val
return None
except Exception:
return None
name = _clean_str(get_val("name"))
face_name = _clean_str(get_val("faceName")) or None
display = face_name or name
if not display:
return None
type_line = _clean_str(row.get("type"))
oracle_text = _clean_multiline(row.get("text"))
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags")))
type_line = _clean_str(get_val("type"))
oracle_text = _clean_multiline(get_val("text"))
raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags")))
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
if not detection.is_background:
return None
@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
face_name=face_name,
display_name=display,
slug=_slugify(display),
color_identity=_parse_color_list(row.get("colorIdentity")),
colors=_parse_color_list(row.get("colors")),
mana_cost=_clean_str(row.get("manaCost")),
mana_value=_parse_float(row.get("manaValue")),
color_identity=_parse_color_list(get_val("colorIdentity")),
colors=_parse_color_list(get_val("colors")),
mana_cost=_clean_str(get_val("manaCost")),
mana_value=_parse_float(get_val("manaValue")),
type_line=type_line,
oracle_text=oracle_text,
keywords=tuple(_split_list(row.get("keywords"))),
keywords=tuple(_split_list(get_val("keywords"))),
theme_tags=tuple(tag for tag in raw_theme_tags if tag),
raw_theme_tags=raw_theme_tags,
edhrec_rank=_parse_int(row.get("edhrecRank")),
layout=_clean_str(row.get("layout")) or "normal",
side=_clean_str(row.get("side")) or None,
edhrec_rank=_parse_int(get_val("edhrecRank")),
layout=_clean_str(get_val("layout")) or "normal",
side=_clean_str(get_val("side")) or None,
)
@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str:
def _parse_literal_list(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
# Handle lists, tuples, sets, and numpy arrays
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
if not text:
return []
@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]:
def _split_list(value: object) -> list[str]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
return [str(item).strip() for item in value if str(item).strip()]
text = _clean_str(value)
if not text:
return []
@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]:
def _parse_color_list(value: object) -> Tuple[str, ...]:
# Check if it's a numpy array (from Parquet/pandas)
is_numpy = False
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
pass
if isinstance(value, (list, tuple, set)) or is_numpy:
parts = [str(item).strip().upper() for item in value if str(item).strip()]
return tuple(parts)
text = _clean_str(value)
if not text:
return tuple()

View file

@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool:
return False
def _extract_colors_from_land_type(type_line: str) -> List[str]:
"""Extract mana colors from basic land types in a type line.
Args:
type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains")
Returns:
List of color letters (e.g., ['R'], ['G', 'W'])
"""
if not isinstance(type_line, str):
return []
type_lower = type_line.lower()
colors = []
basic_land_colors = {
'plains': 'W',
'island': 'U',
'swamp': 'B',
'mountain': 'R',
'forest': 'G',
}
for land_type, color in basic_land_colors.items():
if land_type in type_lower:
colors.append(color)
return colors
def _resolved_csv_dir(base_dir: str | None = None) -> str:
try:
if base_dir:
@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
return {}
# Select only needed columns
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName']
# M9: Added backType to detect MDFC lands where land is on back face
# M9: Added colorIdentity to extract mana colors for MDFC lands
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity']
available_cols = [col for col in usecols if col in df.columns]
if not available_cols:
return {}
@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
multi_df['type'] = multi_df['type'].fillna('').astype(str)
multi_df['side'] = multi_df['side'].fillna('').astype(str)
multi_df['text'] = multi_df['text'].fillna('').astype(str)
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
# M9: Check both type and backType for land faces
if 'backType' in multi_df.columns:
multi_df['backType'] = multi_df['backType'].fillna('').astype(str)
land_mask = (
multi_df['type'].str.contains('land', case=False, na=False) |
multi_df['backType'].str.contains('land', case=False, na=False)
)
land_rows = multi_df[land_mask]
else:
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
if land_rows.empty:
return {}
mapping: Dict[str, Dict[str, Any]] = {}
@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
seen: set[tuple[str, str, str]] = set()
front_is_land = False
layout_val = ''
# M9: Handle merged rows with backType
if len(group) == 1 and 'backType' in group.columns:
row = group.iloc[0]
back_type_val = str(row.get('backType', '') or '')
if back_type_val and 'land' in back_type_val.lower():
# Construct synthetic faces from merged row
front_type = str(row.get('type', '') or '')
front_text = str(row.get('text', '') or '')
mana_cost_val = str(row.get('manaCost', '') or '')
mana_value_raw = row.get('manaValue', '')
mana_value_val = None
try:
if mana_value_raw not in (None, ''):
mana_value_val = float(mana_value_raw)
if math.isnan(mana_value_val):
mana_value_val = None
except Exception:
mana_value_val = None
# Front face
faces.append({
'face': str(row.get('faceName', '') or name),
'side': 'a',
'type': front_type,
'text': front_text,
'mana_cost': mana_cost_val,
'mana_value': mana_value_val,
'produces_mana': _detect_produces_mana(front_text),
'is_land': 'land' in front_type.lower(),
'layout': str(row.get('layout', '') or ''),
})
# Back face (synthesized)
# M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line)
color_identity_raw = row.get('colorIdentity', [])
if isinstance(color_identity_raw, str):
# Handle string format like "['G']" or "G"
try:
import ast
color_identity_raw = ast.literal_eval(color_identity_raw)
except Exception:
color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()]
back_face_colors = list(color_identity_raw) if color_identity_raw else []
# Fallback to parsing land type if colorIdentity not available
if not back_face_colors:
back_face_colors = _extract_colors_from_land_type(back_type_val)
faces.append({
'face': name.split(' // ')[1] if ' // ' in name else 'Back',
'side': 'b',
'type': back_type_val,
'text': '', # Not available in merged row
'mana_cost': '',
'mana_value': None,
'produces_mana': True, # Assume land produces mana
'is_land': True,
'layout': str(row.get('layout', '') or ''),
'colors': back_face_colors, # M9: Color information for mana sources
})
front_is_land = 'land' in front_type.lower()
layout_val = str(row.get('layout', '') or '')
mapping[name] = {
'faces': faces,
'front_is_land': front_is_land,
'layout': layout_val,
'colors': back_face_colors, # M9: Store colors at top level for easy access
}
continue
# Original logic for multi-row format
for _, row in group.iterrows():
side_raw = str(row.get('side', '') or '').strip()
side_key = side_raw.lower()
@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if hasattr(row, 'get'):
row_type_raw = row.get('type', row.get('type_line', '')) or ''
tline_full = str(row_type_raw).lower()
# M9: Check backType for MDFC land detection
back_type_raw = ''
if hasattr(row, 'get'):
back_type_raw = row.get('backType', '') or ''
back_type = str(back_type_raw).lower()
# Land or permanent that could produce mana via text
is_land = ('land' in entry_type) or ('land' in tline_full)
is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type)
base_is_land = is_land
text_field_raw = ''
if hasattr(row, 'get'):
@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if face_types or face_texts:
is_land = True
text_field = text_field_raw.lower().replace('\n', ' ')
# Skip obvious non-permanents (rituals etc.)
# Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land
# M9: If is_land is True (from backType check), we keep it regardless of front face type
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
continue
# Keep only candidates that are lands OR whose text indicates mana production
@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
colors['_dfc_land'] = True
if not (base_is_land or dfc_entry.get('front_is_land')):
colors['_dfc_counts_as_extra'] = True
# M9: Extract colors from DFC face metadata (back face land colors)
dfc_colors = dfc_entry.get('colors', [])
if dfc_colors:
for color in dfc_colors:
if color in colors:
colors[color] = 1
produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C'))
if produces_any_color or colors.get('_dfc_land'):
matrix[name] = colors

View file

@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]:
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
if value is None:
return tuple()
if isinstance(value, (list, tuple, set)):
# Handle numpy arrays, lists, tuples, sets, and other sequences
try:
import numpy as np
is_numpy = isinstance(value, np.ndarray)
except ImportError:
is_numpy = False
if isinstance(value, (list, tuple, set)) or is_numpy:
items = list(value)
else:
text = _safe_str(value)

View file

@ -543,6 +543,9 @@ class ReportingMixin:
mf_info = {}
faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else []
layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None
# M9: If no colors found from mana production, try extracting from face metadata
if not card_colors and isinstance(mf_info, dict):
card_colors = list(mf_info.get('colors', []))
dfc_land_lookup[name] = {
'adds_extra_land': counts_as_extra,
'counts_as_land': not counts_as_extra,
@ -681,13 +684,14 @@ class ReportingMixin:
'faces': faces_meta,
'layout': layout_val,
})
if adds_extra:
dfc_extra_total += copies
# M9: Count ALL MDFC lands for land summary
dfc_extra_total += copies
total_sources = sum(source_counts.values())
traditional_lands = type_counts.get('Land', 0)
# M9: dfc_extra_total now contains ALL MDFC lands, not just extras
land_summary = {
'traditional': traditional_lands,
'dfc_lands': dfc_extra_total,
'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands
'with_dfc': traditional_lands + dfc_extra_total,
'dfc_cards': dfc_details,
'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total),

View file

@ -0,0 +1,567 @@
"""
Card image caching system.
Downloads and manages local cache of Magic: The Gathering card images
from Scryfall, with graceful fallback to API when images are missing.
Features:
- Optional caching (disabled by default for open source users)
- Uses Scryfall bulk data API (respects rate limits and guidelines)
- Downloads from Scryfall CDN (no rate limits on image files)
- Progress tracking for long downloads
- Resume capability if interrupted
- Graceful fallback to API if images missing
Environment Variables:
CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0)
Image Sizes:
- small: 160px width (for list views)
- normal: 488px width (for prominent displays, hover previews)
Directory Structure:
card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB)
card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB)
See: https://scryfall.com/docs/api
"""
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Optional
from urllib.request import Request, urlopen
from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient
logger = logging.getLogger(__name__)
# Scryfall CDN has no rate limits, but we'll be conservative
DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec)
# Image sizes to cache
IMAGE_SIZES = ["small", "normal"]
# Card name sanitization (filesystem-safe)
INVALID_CHARS = r'[<>:"/\\|?*]'
def sanitize_filename(card_name: str) -> str:
"""
Sanitize card name for use as filename.
Args:
card_name: Original card name
Returns:
Filesystem-safe filename
"""
# Replace invalid characters with underscore
safe_name = re.sub(INVALID_CHARS, "_", card_name)
# Remove multiple consecutive underscores
safe_name = re.sub(r"_+", "_", safe_name)
# Trim leading/trailing underscores
safe_name = safe_name.strip("_")
return safe_name
class ImageCache:
"""Manages local card image cache."""
def __init__(
self,
base_dir: str = "card_files/images",
bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json",
):
"""
Initialize image cache.
Args:
base_dir: Base directory for cached images
bulk_data_path: Path to Scryfall bulk data JSON
"""
self.base_dir = Path(base_dir)
self.bulk_data_path = Path(bulk_data_path)
self.client = ScryfallBulkDataClient()
self._last_download_time: float = 0.0
def is_enabled(self) -> bool:
"""Check if image caching is enabled via environment variable."""
return os.getenv("CACHE_CARD_IMAGES", "0") == "1"
def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]:
"""
Get local path to cached image if it exists.
Args:
card_name: Card name
size: Image size ('small' or 'normal')
Returns:
Path to cached image, or None if not cached
"""
if not self.is_enabled():
return None
safe_name = sanitize_filename(card_name)
image_path = self.base_dir / size / f"{safe_name}.jpg"
if image_path.exists():
return image_path
return None
def get_image_url(self, card_name: str, size: str = "normal") -> str:
"""
Get image URL (local path if cached, Scryfall API otherwise).
Args:
card_name: Card name
size: Image size ('small' or 'normal')
Returns:
URL or local path to image
"""
# Check local cache first
local_path = self.get_image_path(card_name, size)
if local_path:
# Return as static file path for web serving
return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg"
# Fallback to Scryfall API
from urllib.parse import quote
card_query = quote(card_name)
return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}"
def _rate_limit_wait(self) -> None:
"""Wait to respect rate limits between downloads."""
elapsed = time.time() - self._last_download_time
if elapsed < DOWNLOAD_DELAY:
time.sleep(DOWNLOAD_DELAY - elapsed)
self._last_download_time = time.time()
def _download_image(self, image_url: str, output_path: Path) -> bool:
"""
Download single image from Scryfall CDN.
Args:
image_url: Image URL from bulk data
output_path: Local path to save image
Returns:
True if successful, False otherwise
"""
self._rate_limit_wait()
try:
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
req = Request(image_url)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=30) as response:
image_data = response.read()
with open(output_path, "wb") as f:
f.write(image_data)
return True
except Exception as e:
logger.debug(f"Failed to download {image_url}: {e}")
# Clean up partial download
if output_path.exists():
output_path.unlink()
return False
def _load_bulk_data(self) -> list[dict[str, Any]]:
"""
Load card data from bulk data JSON.
Returns:
List of card objects with image URLs
Raises:
FileNotFoundError: If bulk data file doesn't exist
json.JSONDecodeError: If file is invalid JSON
"""
if not self.bulk_data_path.exists():
raise FileNotFoundError(
f"Bulk data file not found: {self.bulk_data_path}. "
"Run download_bulk_data() first."
)
logger.info(f"Loading bulk data from {self.bulk_data_path}")
with open(self.bulk_data_path, "r", encoding="utf-8") as f:
return json.load(f)
def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Filter bulk data to only cards in our all_cards.parquet file.
Deduplicates by card name (takes first printing only).
Args:
bulk_cards: Full Scryfall bulk data
Returns:
Filtered list of cards matching our dataset (one per unique name)
"""
try:
import pandas as pd
from code.path_util import get_processed_cards_path
# Load our card names
parquet_path = get_processed_cards_path()
df = pd.read_parquet(parquet_path, columns=["name"])
our_card_names = set(df["name"].str.lower())
logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset")
# Filter and deduplicate - keep only first printing of each card
seen_names = set()
filtered = []
for card in bulk_cards:
card_name_lower = card.get("name", "").lower()
if card_name_lower in our_card_names and card_name_lower not in seen_names:
filtered.append(card)
seen_names.add(card_name_lower)
logger.info(f"Filtered to {len(filtered)} unique cards with image data")
return filtered
except Exception as e:
logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.")
return bulk_cards
def download_bulk_data(self, progress_callback=None) -> None:
"""
Download latest Scryfall bulk data JSON.
Args:
progress_callback: Optional callback(bytes_downloaded, total_bytes)
Raises:
Exception: If download fails
"""
logger.info("Downloading Scryfall bulk data...")
self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True)
self.client.get_bulk_data(
output_path=str(self.bulk_data_path),
progress_callback=progress_callback,
)
logger.info("Bulk data download complete")
def download_images(
self,
sizes: Optional[list[str]] = None,
progress_callback=None,
max_cards: Optional[int] = None,
) -> dict[str, int]:
"""
Download card images from Scryfall CDN.
Args:
sizes: Image sizes to download (default: ['small', 'normal'])
progress_callback: Optional callback(current, total, card_name)
max_cards: Maximum cards to download (for testing)
Returns:
Dictionary with download statistics
Raises:
FileNotFoundError: If bulk data not available
"""
if not self.is_enabled():
logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)")
return {"skipped": 0}
if sizes is None:
sizes = IMAGE_SIZES
logger.info(f"Starting image download for sizes: {sizes}")
# Load bulk data and filter to our cards
bulk_cards = self._load_bulk_data()
cards = self._filter_to_our_cards(bulk_cards)
total_cards = len(cards) if max_cards is None else min(max_cards, len(cards))
stats = {
"total": total_cards,
"downloaded": 0,
"skipped": 0,
"failed": 0,
}
for i, card in enumerate(cards[:total_cards]):
card_name = card.get("name")
if not card_name:
stats["skipped"] += 1
continue
# Collect all faces to download (single-faced or multi-faced)
faces_to_download = []
# Check if card has direct image_uris (single-faced card)
if card.get("image_uris"):
faces_to_download.append({
"name": card_name,
"image_uris": card["image_uris"],
})
# Handle double-faced cards (get all faces)
elif card.get("card_faces"):
for face_idx, face in enumerate(card["card_faces"]):
if face.get("image_uris"):
# For multi-faced cards, append face name or index
face_name = face.get("name", f"{card_name}_face{face_idx}")
faces_to_download.append({
"name": face_name,
"image_uris": face["image_uris"],
})
# Skip if no faces found
if not faces_to_download:
logger.debug(f"No image URIs for {card_name}")
stats["skipped"] += 1
continue
# Download each face in each requested size
for face in faces_to_download:
face_name = face["name"]
image_uris = face["image_uris"]
for size in sizes:
image_url = image_uris.get(size)
if not image_url:
continue
# Check if already cached
safe_name = sanitize_filename(face_name)
output_path = self.base_dir / size / f"{safe_name}.jpg"
if output_path.exists():
stats["skipped"] += 1
continue
# Download image
if self._download_image(image_url, output_path):
stats["downloaded"] += 1
else:
stats["failed"] += 1
# Progress callback
if progress_callback:
progress_callback(i + 1, total_cards, card_name)
# Invalidate cached summary since we just downloaded new images
self.invalidate_summary_cache()
logger.info(f"Image download complete: {stats}")
return stats
def cache_statistics(self) -> dict[str, Any]:
"""
Get statistics about cached images.
Uses a cached summary.json file to avoid scanning thousands of files.
Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS,
default 7 days, matching the main card data staleness check).
Returns:
Dictionary with cache stats (count, size, etc.)
"""
stats = {"enabled": self.is_enabled()}
if not self.is_enabled():
return stats
summary_file = self.base_dir / "summary.json"
# Get staleness threshold from environment (same as card data check)
try:
refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
except Exception:
refresh_days = 7
if refresh_days <= 0:
# Never consider stale
refresh_seconds = float('inf')
else:
refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds
# Check if summary exists and is recent (less than refresh_seconds old)
use_cached = False
if summary_file.exists():
try:
import time
file_age = time.time() - summary_file.stat().st_mtime
if file_age < refresh_seconds:
use_cached = True
except Exception:
pass
# Try to use cached summary
if use_cached:
try:
import json
with summary_file.open('r', encoding='utf-8') as f:
cached_stats = json.load(f)
stats.update(cached_stats)
return stats
except Exception as e:
logger.warning(f"Could not read cache summary: {e}")
# Regenerate summary (fast - just count files and estimate size)
for size in IMAGE_SIZES:
size_dir = self.base_dir / size
if size_dir.exists():
# Fast count: count .jpg files without statting each one
count = sum(1 for _ in size_dir.glob("*.jpg"))
# Estimate total size based on typical averages to avoid stat() calls
# Small images: ~40 KB avg, Normal images: ~100 KB avg
avg_size_kb = 40 if size == "small" else 100
estimated_size_mb = (count * avg_size_kb) / 1024
stats[size] = {
"count": count,
"size_mb": round(estimated_size_mb, 1),
}
else:
stats[size] = {"count": 0, "size_mb": 0.0}
# Save summary for next time
try:
import json
with summary_file.open('w', encoding='utf-8') as f:
json.dump({k: v for k, v in stats.items() if k != "enabled"}, f)
except Exception as e:
logger.warning(f"Could not write cache summary: {e}")
return stats
def invalidate_summary_cache(self) -> None:
"""Delete the cached summary file to force regeneration on next call."""
if not self.is_enabled():
return
summary_file = self.base_dir / "summary.json"
if summary_file.exists():
try:
summary_file.unlink()
logger.debug("Invalidated cache summary file")
except Exception as e:
logger.warning(f"Could not delete cache summary: {e}")
def main():
"""CLI entry point for image caching."""
import argparse
parser = argparse.ArgumentParser(description="Card image cache management")
parser.add_argument(
"--download",
action="store_true",
help="Download images from Scryfall",
)
parser.add_argument(
"--stats",
action="store_true",
help="Show cache statistics",
)
parser.add_argument(
"--max-cards",
type=int,
help="Maximum cards to download (for testing)",
)
parser.add_argument(
"--sizes",
nargs="+",
default=IMAGE_SIZES,
choices=IMAGE_SIZES,
help="Image sizes to download",
)
parser.add_argument(
"--force",
action="store_true",
help="Force re-download of bulk data even if recent",
)
args = parser.parse_args()
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
cache = ImageCache()
if args.stats:
stats = cache.cache_statistics()
print("\nCache Statistics:")
print(f" Enabled: {stats['enabled']}")
if stats["enabled"]:
for size in IMAGE_SIZES:
if size in stats:
print(
f" {size.capitalize()}: {stats[size]['count']} images "
f"({stats[size]['size_mb']:.1f} MB)"
)
elif args.download:
if not cache.is_enabled():
print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.")
return
# Check if bulk data already exists and is recent (within 24 hours)
bulk_data_exists = cache.bulk_data_path.exists()
bulk_data_age_hours = None
if bulk_data_exists:
import time
age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime
bulk_data_age_hours = age_seconds / 3600
print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)")
# Download bulk data if missing, old, or forced
if not bulk_data_exists or bulk_data_age_hours > 24 or args.force:
print("Downloading Scryfall bulk data...")
def bulk_progress(downloaded, total):
if total > 0:
pct = (downloaded / total) * 100
print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / "
f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r")
cache.download_bulk_data(progress_callback=bulk_progress)
print("\nBulk data downloaded successfully")
else:
print("Bulk data is recent, skipping download (use --force to re-download)")
# Download images
print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...")
def image_progress(current, total, card_name):
pct = (current / total) * 100
print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r")
stats = cache.download_images(
sizes=args.sizes,
progress_callback=image_progress,
max_cards=args.max_cards,
)
print("\n\nDownload complete:")
print(f" Total: {stats['total']}")
print(f" Downloaded: {stats['downloaded']}")
print(f" Skipped: {stats['skipped']}")
print(f" Failed: {stats['failed']}")
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,169 @@
"""
Scryfall Bulk Data API client.
Fetches bulk data JSON files from Scryfall's bulk data API, which provides
all card information including image URLs without hitting rate limits.
See: https://scryfall.com/docs/api/bulk-data
"""
import logging
import os
import time
from typing import Any
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data"
DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database
RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines)
class ScryfallBulkDataClient:
"""Client for fetching Scryfall bulk data."""
def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY):
"""
Initialize Scryfall bulk data client.
Args:
rate_limit_delay: Seconds to wait between API requests (default 100ms)
"""
self.rate_limit_delay = rate_limit_delay
self._last_request_time: float = 0.0
def _rate_limit_wait(self) -> None:
"""Wait to respect rate limits between API calls."""
elapsed = time.time() - self._last_request_time
if elapsed < self.rate_limit_delay:
time.sleep(self.rate_limit_delay - elapsed)
self._last_request_time = time.time()
def _make_request(self, url: str) -> Any:
"""
Make HTTP request with rate limiting and error handling.
Args:
url: URL to fetch
Returns:
Parsed JSON response
Raises:
Exception: If request fails after retries
"""
self._rate_limit_wait()
try:
req = Request(url)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=30) as response:
import json
return json.loads(response.read().decode("utf-8"))
except Exception as e:
logger.error(f"Failed to fetch {url}: {e}")
raise
def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]:
"""
Get bulk data metadata (download URL, size, last updated).
Args:
bulk_type: Type of bulk data to fetch (default: default_cards)
Returns:
Dictionary with bulk data info including 'download_uri'
Raises:
ValueError: If bulk_type not found
Exception: If API request fails
"""
logger.info(f"Fetching bulk data info for type: {bulk_type}")
response = self._make_request(BULK_DATA_API_URL)
# Find the requested bulk data type
for item in response.get("data", []):
if item.get("type") == bulk_type:
logger.info(
f"Found bulk data: {item.get('name')} "
f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, "
f"updated: {item.get('updated_at', 'unknown')})"
)
return item
raise ValueError(f"Bulk data type '{bulk_type}' not found")
def download_bulk_data(
self, download_uri: str, output_path: str, progress_callback=None
) -> None:
"""
Download bulk data JSON file.
Args:
download_uri: Direct download URL from get_bulk_data_info()
output_path: Local path to save the JSON file
progress_callback: Optional callback(bytes_downloaded, total_bytes)
Raises:
Exception: If download fails
"""
logger.info(f"Downloading bulk data from: {download_uri}")
logger.info(f"Saving to: {output_path}")
# No rate limit on bulk data downloads per Scryfall docs
try:
req = Request(download_uri)
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
with urlopen(req, timeout=60) as response:
total_size = int(response.headers.get("Content-Length", 0))
downloaded = 0
chunk_size = 1024 * 1024 # 1MB chunks
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback:
progress_callback(downloaded, total_size)
logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully")
except Exception as e:
logger.error(f"Failed to download bulk data: {e}")
# Clean up partial download
if os.path.exists(output_path):
os.remove(output_path)
raise
def get_bulk_data(
self,
bulk_type: str = DEFAULT_BULK_TYPE,
output_path: str = "card_files/raw/scryfall_bulk_data.json",
progress_callback=None,
) -> str:
"""
Fetch bulk data info and download the JSON file.
Args:
bulk_type: Type of bulk data to fetch
output_path: Where to save the JSON file
progress_callback: Optional progress callback
Returns:
Path to downloaded file
Raises:
Exception: If fetch or download fails
"""
info = self.get_bulk_data_info(bulk_type)
download_uri = info["download_uri"]
self.download_bulk_data(download_uri, output_path, progress_callback)
return output_path

View file

@ -349,6 +349,44 @@ def initial_setup() -> None:
logger.info(f" Raw: {raw_path}")
logger.info(f" Processed: {processed_path}")
logger.info("=" * 80)
# Step 3: Optional image caching (if enabled)
try:
from code.file_setup.image_cache import ImageCache
cache = ImageCache()
if cache.is_enabled():
logger.info("=" * 80)
logger.info("Card image caching enabled - starting download")
logger.info("=" * 80)
# Download bulk data
logger.info("Downloading Scryfall bulk data...")
cache.download_bulk_data()
# Download images
logger.info("Downloading card images (this may take 1-2 hours)...")
def progress(current, total, card_name):
if current % 100 == 0: # Log every 100 cards
pct = (current / total) * 100
logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}")
stats = cache.download_images(progress_callback=progress)
logger.info("=" * 80)
logger.info("✓ Image cache complete")
logger.info(f" Downloaded: {stats['downloaded']}")
logger.info(f" Skipped: {stats['skipped']}")
logger.info(f" Failed: {stats['failed']}")
logger.info("=" * 80)
else:
logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)")
logger.info("Images will be fetched from Scryfall API on demand")
except Exception as e:
logger.error(f"Failed to cache images (continuing anyway): {e}")
logger.error("Images will be fetched from Scryfall API on demand")
def regenerate_processed_parquet() -> None:

View file

@ -240,6 +240,13 @@ def merge_multi_face_rows(
faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()]
# M9: Capture back face type for MDFC land detection
if len(group_sorted) >= 2 and "type" in group_sorted.columns:
back_face_row = group_sorted.iloc[1]
back_type = str(back_face_row.get("type", "") or "")
if back_type:
work_df.at[primary_idx, "backType"] = back_type
drop_indices.extend(group_sorted.index[1:])
merged_count += 1

View file

@ -23,6 +23,9 @@ from .services.theme_catalog_loader import prewarm_common_filters, load_index #
from .services.commander_catalog_loader import load_commander_catalog # type: ignore
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
# Logger for app-level logging
logger = logging.getLogger(__name__)
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
_TEMPLATES_DIR = _THIS_DIR / "templates"
@ -99,6 +102,32 @@ if _STATIC_DIR.exists():
# Jinja templates
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
# Add custom Jinja2 filter for card image URLs
def card_image_url(card_name: str, size: str = "normal") -> str:
"""
Generate card image URL (uses local cache if available, falls back to Scryfall).
For DFC cards (containing ' // '), extracts the front face name.
Args:
card_name: Name of the card (may be "Front // Back" for DFCs)
size: Image size ('small' or 'normal')
Returns:
URL for the card image
"""
from urllib.parse import quote
# Extract front face name for DFCs (thumbnails always show front face)
display_name = card_name
if ' // ' in card_name:
display_name = card_name.split(' // ')[0].strip()
# Use our API endpoint which handles cache lookup and fallback
return f"/api/images/{size}/{quote(display_name)}"
templates.env.filters["card_image"] = card_image_url
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
# and reorder to the new signature TemplateResponse(request, name, {...}).
# Prevents DeprecationWarning noise in tests without touching all call sites.
@ -840,6 +869,12 @@ async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
@app.get("/docs/components", response_class=HTMLResponse)
async def components_library(request: Request) -> HTMLResponse:
"""M2 Component Library - showcase of standardized UI components"""
return templates.TemplateResponse("docs/components.html", {"request": request})
# Simple health check (hardened)
@app.get("/healthz")
async def healthz():
@ -2212,6 +2247,13 @@ async def setup_status():
return JSONResponse({"running": False, "phase": "error"})
# ============================================================================
# Card Image Serving Endpoint - MOVED TO /routes/api.py
# ============================================================================
# Image serving logic has been moved to code/web/routes/api.py
# The router is included below via: app.include_router(api_routes.router)
# Routers
from .routes import build as build_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402
@ -2225,6 +2267,7 @@ from .routes import telemetry as telemetry_routes # noqa: E402
from .routes import cards as cards_routes # noqa: E402
from .routes import card_browser as card_browser_routes # noqa: E402
from .routes import compare as compare_routes # noqa: E402
from .routes import api as api_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
@ -2237,6 +2280,7 @@ app.include_router(telemetry_routes.router)
app.include_router(cards_routes.router)
app.include_router(card_browser_routes.router)
app.include_router(compare_routes.router)
app.include_router(api_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev
try:

299
code/web/routes/api.py Normal file
View file

@ -0,0 +1,299 @@
"""API endpoints for web services."""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from urllib.parse import quote_plus
from fastapi import APIRouter, Query
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from code.file_setup.image_cache import ImageCache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Global image cache instance
_image_cache = ImageCache()
@router.get("/images/status")
async def get_download_status():
"""
Get current image download status.
Returns:
JSON response with download status
"""
import json
status_file = Path("card_files/images/.download_status.json")
if not status_file.exists():
# Check cache statistics if no download in progress
stats = _image_cache.cache_statistics()
return JSONResponse({
"running": False,
"stats": stats
})
try:
with status_file.open('r', encoding='utf-8') as f:
status = json.load(f)
return JSONResponse(status)
except Exception as e:
logger.warning(f"Could not read status file: {e}")
return JSONResponse({
"running": False,
"error": str(e)
})
@router.get("/images/debug")
async def get_image_debug():
"""
Debug endpoint to check image cache configuration.
Returns:
JSON with debug information
"""
import os
from pathlib import Path
base_dir = Path(_image_cache.base_dir)
debug_info = {
"cache_enabled": _image_cache.is_enabled(),
"env_var": os.getenv("CACHE_CARD_IMAGES", "not set"),
"base_dir": str(base_dir),
"base_dir_exists": base_dir.exists(),
"small_dir": str(base_dir / "small"),
"small_dir_exists": (base_dir / "small").exists(),
"normal_dir": str(base_dir / "normal"),
"normal_dir_exists": (base_dir / "normal").exists(),
}
# Count files if directories exist
if (base_dir / "small").exists():
debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg")))
if (base_dir / "normal").exists():
debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg")))
# Test with a sample card name
test_card = "Lightning Bolt"
debug_info["test_card"] = test_card
test_path_small = _image_cache.get_image_path(test_card, "small")
test_path_normal = _image_cache.get_image_path(test_card, "normal")
debug_info["test_path_small"] = str(test_path_small) if test_path_small else None
debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None
debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False
debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False
return JSONResponse(debug_info)
@router.get("/images/{size}/{card_name}")
async def get_card_image(size: str, card_name: str, face: str = Query(default="front")):
"""
Serve card image from cache or redirect to Scryfall API.
Args:
size: Image size ('small' or 'normal')
card_name: Name of the card
face: Which face to show ('front' or 'back') for DFC cards
Returns:
FileResponse if cached locally, RedirectResponse to Scryfall API otherwise
"""
# Validate size parameter
if size not in ["small", "normal"]:
size = "normal"
# Check if caching is enabled
cache_enabled = _image_cache.is_enabled()
# Check if image exists in cache
if cache_enabled:
image_path = None
# For DFC cards, handle front/back faces differently
if " // " in card_name:
if face == "back":
# For back face, ONLY try the back face name
back_face = card_name.split(" // ")[1].strip()
logger.debug(f"DFC back face requested: {back_face}")
image_path = _image_cache.get_image_path(back_face, size)
else:
# For front face (or unspecified), try front face name
front_face = card_name.split(" // ")[0].strip()
logger.debug(f"DFC front face requested: {front_face}")
image_path = _image_cache.get_image_path(front_face, size)
else:
# Single-faced card, try exact name
image_path = _image_cache.get_image_path(card_name, size)
if image_path and image_path.exists():
logger.info(f"Serving cached image: {card_name} ({size}, {face})")
return FileResponse(
image_path,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=31536000", # 1 year
}
)
else:
logger.debug(f"No cached image found for: {card_name} (face: {face})")
# Fallback to Scryfall API
# For back face requests of DFC cards, we need the full card name
scryfall_card_name = card_name
scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}"
# If this is a back face request, try to find the full DFC name
if face == "back":
try:
from code.services.all_cards_loader import AllCardsLoader
loader = AllCardsLoader()
df = loader.load()
# Look for cards where this face name appears in the card_faces
# The card name format is "Front // Back"
matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)]
if not matching.empty:
# Find DFC cards (containing ' // ')
dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)]
if not dfc_matches.empty:
# Use the first matching DFC card's full name
full_name = dfc_matches.iloc[0]['name']
scryfall_card_name = full_name
# Add face parameter to Scryfall request
scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back"
except Exception as e:
logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}")
scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}"
return RedirectResponse(scryfall_url)
@router.post("/images/download")
async def download_images():
"""
Start downloading card images in background.
Returns:
JSON response with status
"""
if not _image_cache.is_enabled():
return JSONResponse({
"ok": False,
"message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable."
}, status_code=400)
# Write initial status
try:
status_dir = Path("card_files/images")
status_dir.mkdir(parents=True, exist_ok=True)
status_file = status_dir / ".download_status.json"
import json
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "bulk_data",
"message": "Downloading Scryfall bulk data...",
"current": 0,
"total": 0,
"percentage": 0
}, f)
except Exception as e:
logger.warning(f"Could not write initial status: {e}")
# Start download in background thread
def _download_task():
import json
status_file = Path("card_files/images/.download_status.json")
try:
# Download bulk data first
logger.info("[IMAGE DOWNLOAD] Starting bulk data download...")
def bulk_progress(downloaded: int, total: int):
"""Progress callback for bulk data download."""
try:
percentage = int(downloaded / total * 100) if total > 0 else 0
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "bulk_data",
"message": f"Downloading bulk data: {percentage}%",
"current": downloaded,
"total": total,
"percentage": percentage
}, f)
except Exception as e:
logger.warning(f"Could not update bulk progress: {e}")
_image_cache.download_bulk_data(progress_callback=bulk_progress)
# Download images
logger.info("[IMAGE DOWNLOAD] Starting image downloads...")
def image_progress(current: int, total: int, card_name: str):
"""Progress callback for image downloads."""
try:
percentage = int(current / total * 100) if total > 0 else 0
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": True,
"phase": "images",
"message": f"Downloading images: {card_name}",
"current": current,
"total": total,
"percentage": percentage
}, f)
# Log progress every 100 cards
if current % 100 == 0:
logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)")
except Exception as e:
logger.warning(f"Could not update image progress: {e}")
stats = _image_cache.download_images(progress_callback=image_progress)
# Write completion status
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": False,
"phase": "complete",
"message": f"Download complete: {stats.get('downloaded', 0)} new images",
"stats": stats,
"percentage": 100
}, f)
logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}")
except Exception as e:
logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True)
try:
with status_file.open('w', encoding='utf-8') as f:
json.dump({
"running": False,
"phase": "error",
"message": f"Download failed: {str(e)}",
"percentage": 0
}, f)
except Exception:
pass
# Start background thread
thread = threading.Thread(target=_download_task, daemon=True)
thread.start()
return JSONResponse({
"ok": True,
"message": "Image download started in background"
}, status_code=202)

View file

@ -25,6 +25,7 @@ from ..services.build_utils import (
owned_set as owned_set_helper,
builder_present_names,
builder_display_map,
commander_hover_context,
)
from ..app import templates
from deck_builder import builder_constants as bc
@ -1349,6 +1350,14 @@ async def build_new_modal(request: Request) -> HTMLResponse:
for key in skip_keys:
sess.pop(key, None)
# M2: Clear commander and form selections for fresh start
commander_keys = [
"commander", "partner", "background", "commander_mode",
"themes", "bracket"
]
for key in commander_keys:
sess.pop(key, None)
theme_context = _custom_theme_context(request, sess)
ctx = {
"request": request,
@ -1483,20 +1492,14 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
merged_tags.append(token)
ctx["tags"] = merged_tags
# Deduplicate recommended: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
existing_recommended = ctx.get("recommended") or []
merged_recommended: list[str] = []
rec_seen: set[str] = set()
for source in (partner_tags, existing_recommended):
for tag in source:
token = str(tag).strip()
if not token:
continue
key = token.casefold()
if key in rec_seen:
continue
rec_seen.add(key)
merged_recommended.append(token)
ctx["recommended"] = merged_recommended
deduplicated_recommended = [
tag for tag in existing_recommended
if str(tag).strip().casefold() not in partner_tags_lower
]
ctx["recommended"] = deduplicated_recommended
reason_map = dict(ctx.get("recommended_reasons") or {})
for tag in partner_tags:
@ -2907,6 +2910,11 @@ async def build_step2_get(request: Request) -> HTMLResponse:
if is_gc and (sel_br is None or int(sel_br) < 3):
sel_br = 3
partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS)
import logging
logger = logging.getLogger(__name__)
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
context = {
"request": request,
"commander": {"name": commander},
@ -2940,7 +2948,22 @@ async def build_step2_get(request: Request) -> HTMLResponse:
)
partner_tags = context.pop("partner_theme_tags", None)
if partner_tags:
import logging
logger = logging.getLogger(__name__)
context["tags"] = partner_tags
# Deduplicate recommended tags: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
original_recommended = context.get("recommended", [])
deduplicated_recommended = [
tag for tag in original_recommended
if str(tag).strip().casefold() not in partner_tags_lower
]
logger.info(
f"Step2: partner_tags={len(partner_tags)}, "
f"original_recommended={len(original_recommended)}, "
f"deduplicated_recommended={len(deduplicated_recommended)}"
)
context["recommended"] = deduplicated_recommended
resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@ -3266,6 +3289,57 @@ async def build_step3_get(request: Request) -> HTMLResponse:
sess["last_step"] = 3
defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults
# Check if any skip flags are enabled to show skeleton automation page
skip_flags = {
"skip_lands": "land selection",
"skip_to_misc": "land selection",
"skip_basics": "basic lands",
"skip_staples": "staple lands",
"skip_kindred": "kindred lands",
"skip_fetches": "fetch lands",
"skip_duals": "dual lands",
"skip_triomes": "triome lands",
"skip_all_creatures": "creature selection",
"skip_creature_primary": "primary creatures",
"skip_creature_secondary": "secondary creatures",
"skip_creature_fill": "creature fills",
"skip_all_spells": "spell selection",
"skip_ramp": "ramp spells",
"skip_removal": "removal spells",
"skip_wipes": "board wipes",
"skip_card_advantage": "card advantage spells",
"skip_protection": "protection spells",
"skip_spell_fill": "spell fills",
}
active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)]
if active_skips:
# Show skeleton automation page with auto-submit
automation_parts = []
if any("land" in s for s in active_skips):
automation_parts.append("lands")
if any("creature" in s for s in active_skips):
automation_parts.append("creatures")
if any("spell" in s for s in active_skips):
automation_parts.append("spells")
automation_message = f"Applying default values for {', '.join(automation_parts)}..."
resp = templates.TemplateResponse(
"build/_step3_skeleton.html",
{
"request": request,
"defaults": defaults,
"commander": sess.get("commander"),
"automation_message": automation_message,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# No skips enabled, show normal form
resp = templates.TemplateResponse(
"build/_step3.html",
{
@ -3844,6 +3918,16 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe
ctx["synergies"] = synergies
ctx["summary_ready"] = True
ctx["summary_token"] = active_token
# Add commander hover context for color identity and theme tags
hover_meta = commander_hover_context(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=summary_data,
combined=ctx.get("combined_commander"),
)
ctx.update(hover_meta)
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response

View file

@ -195,7 +195,11 @@ async def download_github():
@router.get("/", response_class=HTMLResponse)
async def setup_index(request: Request) -> HTMLResponse:
import code.settings as settings
from code.file_setup.image_cache import ImageCache
image_cache = ImageCache()
return templates.TemplateResponse("setup/index.html", {
"request": request,
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
"image_cache_enabled": image_cache.is_enabled()
})

View file

@ -291,28 +291,6 @@ def _diag_enabled() -> bool:
return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"}
@router.get("/picker", response_class=HTMLResponse)
async def theme_picker_page(request: Request):
"""Render the theme picker shell.
Dynamic data (list, detail) loads via fragment endpoints. We still inject
known archetype list for the filter select so it is populated on initial load.
"""
archetypes: list[str] = []
try:
idx = load_index()
archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type]
except Exception:
archetypes = []
return _templates.TemplateResponse(
"themes/picker.html",
{
"request": request,
"archetypes": archetypes,
"theme_picker_diagnostics": _diag_enabled(),
},
)
@router.get("/metrics")
async def theme_metrics():
if not _diag_enabled():
@ -746,89 +724,9 @@ async def api_theme_preview(
return JSONResponse({"ok": True, "preview": payload})
@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse)
async def theme_preview_fragment(
theme_id: str,
limit: int = Query(12, ge=1, le=30),
colors: str | None = None,
commander: str | None = None,
suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"),
minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale used in detail page collapsible preview)"),
request: Request = None,
):
"""Return HTML fragment for theme preview with caching headers.
Adds ETag and Last-Modified headers (no strong caching enables conditional GET / 304).
ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander).
"""
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
return HTMLResponse("<div class='error'>Theme not found.</div>", status_code=404)
# Load example commanders (authoritative list) from catalog detail for legality instead of inferring
example_commanders: list[str] = []
synergy_commanders: list[str] = []
try:
idx = load_index()
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
if entry:
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False)
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
# De-duplicate any overlap with example commanders while preserving order
seen = set(example_commanders)
for c in synergy_commanders_raw:
if c not in seen:
synergy_commanders.append(c)
seen.add(c)
except Exception:
example_commanders = []
synergy_commanders = []
# Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift)
import hashlib
import json as _json
import time as _time
try:
idx = load_index()
catalog_tag = idx.etag
except Exception:
catalog_tag = "unknown"
hash_src = _json.dumps({
"theme": theme_id,
"limit": limit,
"commander": commander,
"sample": payload.get("sample", [])[:3], # small slice for stability & speed
"v": 1,
}, sort_keys=True).encode("utf-8")
etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}"
# Conditional request support
if request is not None:
inm = request.headers.get("if-none-match")
if inm and inm == etag:
# 304 Not Modified FastAPI HTMLResponse with empty body & headers
resp = HTMLResponse(status_code=304, content="")
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
ctx = {
"request": request,
"preview": payload,
"example_commanders": example_commanders,
"synergy_commanders": synergy_commanders,
"theme_id": theme_id,
"etag": etag,
"suppress_curated": suppress_curated,
"minimal": minimal,
}
resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx)
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
@router.get("/fragment/list", response_class=HTMLResponse)
# --- Preview Export Endpoints (CSV / JSON) ---

View file

@ -310,13 +310,30 @@ def commander_hover_context(
raw_color_identity = combined_info.get("color_identity") if combined_info else None
commander_color_identity: list[str] = []
# If we have a combined commander (partner/background), use its color identity
if isinstance(raw_color_identity, (list, tuple, set)):
for item in raw_color_identity:
token = str(item).strip().upper()
if token:
commander_color_identity.append(token)
# M7: For non-partner commanders, also check summary.colors for color identity
# For regular commanders (no partner/background), look up from commander catalog first
if not commander_color_identity and not has_combined and commander_name:
try:
from .commander_catalog_loader import find_commander_record
record = find_commander_record(commander_name)
if record and hasattr(record, 'color_identity'):
raw_ci = record.color_identity
if isinstance(raw_ci, (list, tuple, set)):
for item in raw_ci:
token = str(item).strip().upper()
if token:
commander_color_identity.append(token)
except Exception:
pass
# Fallback: check summary.colors if we still don't have color identity
if not commander_color_identity and not has_combined and isinstance(summary, dict):
summary_colors = summary.get("colors")
if isinstance(summary_colors, (list, tuple, set)):

View file

@ -0,0 +1,375 @@
/**
* M2 Component Library - JavaScript Utilities
*
* Core functions for interactive components:
* - Card flip button (dual-faced cards)
* - Collapsible panels
* - Card popups
* - Modal management
*/
// ============================================
// CARD FLIP FUNCTIONALITY
// ============================================
/**
* Flip a dual-faced card image between front and back faces
* @param {HTMLElement} button - The flip button element
*/
function flipCard(button) {
const container = button.closest('.card-thumb-container, .card-popup-image');
if (!container) return;
const img = container.querySelector('img');
if (!img) return;
const cardName = img.dataset.cardName;
if (!cardName) return;
const faces = cardName.split(' // ');
if (faces.length < 2) return;
// Determine current face (default to 0 = front)
const currentFace = parseInt(img.dataset.currentFace || '0', 10);
const nextFace = currentFace === 0 ? 1 : 0;
const faceName = faces[nextFace];
// Determine image version based on container
const isLarge = container.classList.contains('card-thumb-large') ||
container.classList.contains('card-popup-image');
const version = isLarge ? 'normal' : 'small';
// Update image source
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`;
img.alt = `${faceName} image`;
img.dataset.currentFace = nextFace.toString();
// Update button aria-label
const otherFace = faces[currentFace];
button.setAttribute('aria-label', `Flip to ${otherFace}`);
}
/**
* Reset all card images to show front face
* Useful when navigating between pages or clearing selections
*/
function resetCardFaces() {
document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => {
const cardName = img.dataset.cardName;
const faces = cardName.split(' // ');
if (faces.length > 1) {
const frontFace = faces[0];
const container = img.closest('.card-thumb-container, .card-popup-image');
const isLarge = container && (container.classList.contains('card-thumb-large') ||
container.classList.contains('card-popup-image'));
const version = isLarge ? 'normal' : 'small';
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`;
img.alt = `${frontFace} image`;
img.dataset.currentFace = '0';
}
});
}
// ============================================
// COLLAPSIBLE PANEL FUNCTIONALITY
// ============================================
/**
* Toggle a collapsible panel's expanded/collapsed state
* @param {string} panelId - The ID of the panel element
*/
function togglePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-collapse-content');
if (!button || !content) return;
const isExpanded = button.getAttribute('aria-expanded') === 'true';
// Toggle state
button.setAttribute('aria-expanded', (!isExpanded).toString());
content.style.display = isExpanded ? 'none' : 'block';
// Toggle classes
panel.classList.toggle('panel-expanded', !isExpanded);
panel.classList.toggle('panel-collapsed', isExpanded);
}
/**
* Expand a collapsible panel
* @param {string} panelId - The ID of the panel element
*/
function expandPanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-collapse-content');
if (!button || !content) return;
button.setAttribute('aria-expanded', 'true');
content.style.display = 'block';
panel.classList.add('panel-expanded');
panel.classList.remove('panel-collapsed');
}
/**
* Collapse a collapsible panel
* @param {string} panelId - The ID of the panel element
*/
function collapsePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-collapse-content');
if (!button || !content) return;
button.setAttribute('aria-expanded', 'false');
content.style.display = 'none';
panel.classList.add('panel-collapsed');
panel.classList.remove('panel-expanded');
}
// ============================================
// MODAL MANAGEMENT
// ============================================
/**
* Open a modal by ID
* @param {string} modalId - The ID of the modal element
*/
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Focus first focusable element in modal
const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable) {
setTimeout(() => focusable.focus(), 100);
}
}
/**
* Close a modal by ID or element
* @param {string|HTMLElement} modalOrId - Modal element or ID
*/
function closeModal(modalOrId) {
const modal = typeof modalOrId === 'string'
? document.getElementById(modalOrId)
: modalOrId;
if (!modal) return;
modal.remove();
// Restore body scroll if no other modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
/**
* Close all open modals
*/
function closeAllModals() {
document.querySelectorAll('.modal').forEach(modal => modal.remove());
document.body.style.overflow = '';
}
// ============================================
// CARD POPUP FUNCTIONALITY
// ============================================
/**
* Show card details popup on hover or tap
* @param {string} cardName - The card name
* @param {Object} options - Popup options
* @param {string[]} options.tags - Card tags
* @param {string[]} options.highlightTags - Tags to highlight
* @param {string} options.role - Card role
* @param {string} options.layout - Card layout (for flip button)
*/
function showCardPopup(cardName, options = {}) {
// Remove any existing popup
closeCardPopup();
const {
tags = [],
highlightTags = [],
role = '',
layout = 'normal'
} = options;
const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout);
const baseName = cardName.split(' // ')[0];
// Create popup HTML
const popup = document.createElement('div');
popup.className = 'card-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', `${cardName} details`);
let tagsHTML = '';
if (tags.length > 0) {
tagsHTML = '<div class="card-popup-tags">';
tags.forEach(tag => {
const isHighlight = highlightTags.includes(tag);
tagsHTML += `<span class="card-popup-tag${isHighlight ? ' card-popup-tag-highlight' : ''}">${tag}</span>`;
});
tagsHTML += '</div>';
}
let roleHTML = '';
if (role) {
roleHTML = `<div class="card-popup-role">Role: <span>${role}</span></div>`;
}
let flipButtonHTML = '';
if (isDFC) {
flipButtonHTML = `
<button type="button" class="card-flip-btn" onclick="flipCard(this)" aria-label="Flip card">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
</svg>
</button>
`;
}
popup.innerHTML = `
<div class="card-popup-backdrop" onclick="closeCardPopup()"></div>
<div class="card-popup-content">
<div class="card-popup-image">
<img src="https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(baseName)}&format=image&version=normal"
alt="${cardName} image"
data-card-name="${cardName}"
loading="lazy"
decoding="async" />
${flipButtonHTML}
</div>
<div class="card-popup-info">
<h3 class="card-popup-name">${cardName}</h3>
${roleHTML}
${tagsHTML}
</div>
<button type="button" class="card-popup-close" onclick="closeCardPopup()" aria-label="Close">×</button>
</div>
`;
document.body.appendChild(popup);
document.body.style.overflow = 'hidden';
// Focus close button
const closeBtn = popup.querySelector('.card-popup-close');
if (closeBtn) {
setTimeout(() => closeBtn.focus(), 100);
}
}
/**
* Close card popup
* @param {HTMLElement} [element] - Element to search from (optional)
*/
function closeCardPopup(element) {
const popup = element
? element.closest('.card-popup')
: document.querySelector('.card-popup');
if (popup) {
popup.remove();
// Restore body scroll if no modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
}
/**
* Setup card thumbnail hover/tap events
* Call this after dynamically adding card thumbnails to the DOM
*/
function setupCardPopups() {
document.querySelectorAll('.card-thumb-container[data-card-name]').forEach(container => {
const img = container.querySelector('.card-thumb');
if (!img) return;
const cardName = container.dataset.cardName || img.dataset.cardName;
if (!cardName) return;
// Desktop: hover
container.addEventListener('mouseenter', function(e) {
if (window.innerWidth > 768) {
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const role = img.dataset.role || '';
const layout = img.dataset.layout || 'normal';
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
}
});
// Mobile: tap
container.addEventListener('click', function(e) {
if (window.innerWidth <= 768) {
e.preventDefault();
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const role = img.dataset.role || '';
const layout = img.dataset.layout || 'normal';
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
}
});
});
}
// ============================================
// INITIALIZATION
// ============================================
// Setup event listeners when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Setup card popups on initial load
setupCardPopups();
// Close modals/popups on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeCardPopup();
// Close topmost modal only
const modals = document.querySelectorAll('.modal');
if (modals.length > 0) {
closeModal(modals[modals.length - 1]);
}
}
});
});
} else {
// DOM already loaded
setupCardPopups();
}
// Export functions for use in other scripts or inline handlers
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
flipCard,
resetCardFaces,
togglePanel,
expandPanel,
collapsePanel,
openModal,
closeModal,
closeAllModals,
showCardPopup,
closeCardPopup,
setupCardPopups
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,643 @@
/* Shared Component Styles - Not processed by Tailwind PurgeCSS */
/* Card-style list items (used in theme catalog, commander browser, etc.) */
.theme-list-card {
background: var(--panel);
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: background-color 0.15s ease;
}
.theme-list-card:hover {
background: var(--hover);
}
/* Filter chips (used in theme catalog, card browser, etc.) */
.filter-chip {
background: var(--panel-alt);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.filter-chip-remove {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0;
line-height: 1;
}
/* Loading skeleton cards (used in theme catalog, deck lists, etc.) */
.skeleton-card {
height: 48px;
border-radius: 8px;
background: linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%);
background-size: 200% 100%;
animation: sk 1.2s ease-in-out infinite;
}
/* Search suggestion dropdowns (used in theme catalog, card search, etc.) */
.search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
z-index: 25;
display: none;
max-height: 300px;
overflow: auto;
border-radius: 0 0 8px 8px;
}
.search-suggestions a {
display: block;
padding: 0.5rem 0.6rem;
font-size: 13px;
text-decoration: none;
color: var(--text);
border-bottom: 1px solid var(--border);
transition: background 0.15s ease;
}
.search-suggestions a:last-child {
border-bottom: none;
}
.search-suggestions a:hover,
.search-suggestions a.selected {
background: var(--hover);
}
.search-suggestions a.selected {
border-left: 3px solid var(--ring);
padding-left: calc(0.6rem - 3px);
}
/* Card reference links (clickable card names with hover preview) */
.card-ref {
cursor: pointer;
text-decoration: underline dotted;
}
.card-ref:hover {
color: var(--accent);
}
/* Modal components (used in new deck modal, settings modals, etc.) */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
overflow: auto;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
}
.modal-content {
position: relative;
max-width: 720px;
width: clamp(320px, 90vw, 720px);
background: #0f1115;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 1rem;
max-height: min(92vh, 100%);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
/* Form field components */
.form-label {
display: block;
margin-bottom: 0.5rem;
}
.form-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: 0.5rem;
margin: 0;
width: 100%;
cursor: pointer;
text-align: left;
}
.form-checkbox-label input[type="checkbox"],
.form-checkbox-label input[type="radio"] {
margin: 0;
cursor: pointer;
}
/* Include/Exclude card chips (green/red themed) */
.include-chips-container {
margin-top: 0.5rem;
min-height: 30px;
border: 1px solid #4ade80;
border-radius: 6px;
padding: 0.5rem;
background: rgba(74, 222, 128, 0.05);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: flex-start;
}
.exclude-chips-container {
margin-top: 0.5rem;
min-height: 30px;
border: 1px solid #ef4444;
border-radius: 6px;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.05);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: flex-start;
}
.chips-inner {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
flex: 1;
}
.chips-placeholder {
color: #6b7280;
font-size: 11px;
font-style: italic;
}
/* Card list textarea styling */
.include-textarea {
width: 100%;
min-height: 60px;
resize: vertical;
font-family: monospace;
font-size: 12px;
border-left: 3px solid #4ade80;
color: #1f2937;
background: #ffffff;
}
.include-textarea::placeholder {
color: #9ca3af;
opacity: 0.7;
}
/* Alternative card buttons - force text wrapping */
.alt-option {
display: block !important;
width: 100% !important;
max-width: 100% !important;
text-align: left !important;
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
line-height: 1.3 !important;
padding: 0.5rem 0.7rem !important;
}
.exclude-textarea {
width: 100%;
min-height: 60px;
resize: vertical;
font-family: monospace;
font-size: 12px;
border-left: 3px solid #ef4444;
color: #1f2937;
background: #ffffff;
}
.exclude-textarea::placeholder {
color: #9ca3af;
opacity: 0.7;
}
/* Info/warning panels */
.info-panel {
margin-top: 0.75rem;
padding: 0.5rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 6px;
}
.info-panel summary {
cursor: pointer;
font-size: 12px;
color: #60a5fa;
}
.info-panel-content {
margin-top: 0.5rem;
font-size: 12px;
line-height: 1.5;
}
/* Include/Exclude card list helpers */
.include-exclude-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.include-exclude-grid {
grid-template-columns: 1fr;
}
}
.card-list-label {
display: block;
margin-bottom: 0.5rem;
}
.card-list-label small {
color: #9ca3af;
opacity: 1;
}
.card-list-label-include {
color: #4ade80;
font-weight: 500;
}
.card-list-label-exclude {
color: #ef4444;
font-weight: 500;
}
.card-list-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 12px;
}
.card-list-count {
font-size: 11px;
}
.card-list-validation {
margin-top: 0.5rem;
font-size: 12px;
}
.card-list-badges {
display: flex;
gap: 0.25rem;
font-size: 10px;
}
/* Button variants for include/exclude controls */
.btn-upload-include {
cursor: pointer;
font-size: 11px;
padding: 0.25rem 0.5rem;
background: #065f46;
border-color: #059669;
}
.btn-upload-exclude {
cursor: pointer;
font-size: 11px;
padding: 0.25rem 0.5rem;
background: #7f1d1d;
border-color: #dc2626;
}
.btn-clear {
font-size: 11px;
padding: 0.25rem 0.5rem;
background: #7f1d1d;
border-color: #dc2626;
}
/* Modal footer */
.modal-footer {
display: flex;
gap: 0.5rem;
justify-content: space-between;
margin-top: 1rem;
}
.modal-footer-left {
display: flex;
gap: 0.5rem;
}
/* Chip dot color variants */
.dot-green {
background: var(--green-main);
}
.dot-blue {
background: var(--blue-main);
}
.dot-orange {
background: var(--orange-main, #f97316);
}
.dot-red {
background: var(--red-main);
}
.dot-purple {
background: var(--purple-main, #a855f7);
}
/* Form label with icon */
.form-label-icon {
display: flex;
align-items: center;
gap: 0.35rem;
}
/* Inline form (for control buttons) */
.inline-form {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Locked cards list */
.locked-list {
list-style: none;
padding: 0;
margin: 0.35rem 0 0;
display: grid;
gap: 0.35rem;
}
.locked-item {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.lock-box-inline {
display: inline;
margin-left: auto;
}
/* Build controls sticky section */
.build-controls {
position: sticky;
z-index: 5;
background: linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85));
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
/* Alert box */
.alert-error {
margin-top: 0.5rem;
color: #fecaca;
background: #7f1d1d;
border: 1px solid #991b1b;
padding: 0.5rem 0.75rem;
border-radius: 8px;
}
/* Stage timeline list */
.timeline-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.25rem;
}
.timeline-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Card action buttons container */
.card-actions-center {
display: flex;
justify-content: center;
margin-top: 0.25rem;
gap: 0.35rem;
flex-wrap: wrap;
}
/* Ownership badge (small circular indicator) */
.ownership-badge {
display: inline-block;
border: 1px solid var(--border);
background: rgba(17,24,39,.9);
color: #e5e7eb;
border-radius: 12px;
font-size: 12px;
line-height: 18px;
height: 18px;
min-width: 18px;
padding: 0 6px;
text-align: center;
}
/* Build log pre formatting */
.build-log {
margin-top: 0.5rem;
white-space: pre-wrap;
background: #0f1115;
border: 1px solid var(--border);
padding: 1rem;
border-radius: 8px;
max-height: 40vh;
overflow: auto;
}
/* Last action status area (prevents layout shift) */
.last-action {
min-height: 1.5rem;
}
/* Deck summary section divider */
.summary-divider {
margin: 1.25rem 0;
border-color: var(--border);
}
/* Summary type heading */
.summary-type-heading {
margin: 0.5rem 0 0.25rem 0;
font-weight: 600;
}
/* Summary view controls */
.summary-view-controls {
margin: 0.5rem 0 0.25rem 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Summary section spacing */
.summary-section {
margin-top: 0.5rem;
}
.summary-section-lg {
margin-top: 1rem;
}
/* Land breakdown note chips */
.land-note-chip-expand {
background: #0f172a;
border-color: #34d399;
color: #a7f3d0;
}
.land-note-chip-counts {
background: #111827;
border-color: #60a5fa;
color: #bfdbfe;
}
/* Land breakdown list */
.land-breakdown-list {
list-style: none;
padding: 0;
margin: 0.35rem 0 0;
display: grid;
gap: 0.35rem;
}
.land-breakdown-item {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.land-breakdown-subs {
list-style: none;
padding: 0;
margin: 0.2rem 0 0;
display: grid;
gap: 0.15rem;
flex: 1 0 100%;
}
.land-breakdown-sub {
font-size: 0.85rem;
color: #e5e7eb;
opacity: 0.85;
}
/* Deck metrics wrap */
.deck-metrics-wrap {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-start;
}
/* Combo summary styling */
.combo-summary {
cursor: pointer;
user-select: none;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: #12161c;
font-weight: 600;
}
/* Mana analytics row grid */
.mana-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
align-items: stretch;
}
/* Mana panel container */
.mana-panel {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem;
background: #0f1115;
}
/* Mana panel heading */
.mana-panel-heading {
margin-bottom: 0.35rem;
font-weight: 600;
}
/* Chart bars container */
.chart-bars {
display: flex;
gap: 14px;
align-items: flex-end;
height: 140px;
}
/* Chart column center-aligned text */
.chart-column {
text-align: center;
}
/* Chart SVG cursor */
.chart-svg {
cursor: pointer;
}
/* Existing card tile styles (for reference/consolidation) */
.card-tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: background-color 0.15s ease;
}
.card-tile:hover {
background: var(--hover);
}
/* Theme detail card styles (for reference/consolidation) */
.theme-detail-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}

File diff suppressed because it is too large Load diff

3500
code/web/static/tailwind.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
# Placeholder for TypeScript source files
# TypeScript files will be compiled to code/web/static/js/

View file

@ -39,6 +39,7 @@
window.__telemetryEndpoint = '/telemetry/events';
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
<link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" />
<style>
/* Disable all transitions until page is loaded to prevent sidebar flash */
.no-transition,
@ -63,18 +64,11 @@
<body class="no-transition" data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
<header class="top-banner">
<div class="top-inner">
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
<div class="flex-row banner-left">
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border); flex-shrink: 0;">
☰ Menu
</button>
<h1 style="margin:0;">MTG Deckbuilder</h1>
</div>
<div style="display:flex; align-items:center; gap:.5rem">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
<button type="button" id="btn-open-permalink" class="btn" title="Open a saved permalink"
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
{# Theme controls moved to sidebar #}
<h1 style="margin:0; white-space: nowrap;">MTG Deckbuilder</h1>
</div>
</div>
</header>
@ -128,115 +122,7 @@
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
</footer>
<style>
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); }
.card-hover .dual {
display:flex; gap:12px; align-items:flex-start;
}
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
.card-meta li { margin:.1rem 0; }
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
/* Global theme badge styles (moved from picker for reuse on standalone pages) */
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
.badge-fallback { background:#7f1d1d; color:#fff; }
.badge-quality-draft { background:#4338ca; color:#fff; }
.badge-quality-reviewed { background:#065f46; color:#fff; }
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
.badge-pop-vc { background:#065f46; color:#fff; }
.badge-pop-c { background:#047857; color:#fff; }
.badge-pop-u { background:#0369a1; color:#fff; }
.badge-pop-n { background:#92400e; color:#fff; }
.badge-pop-r { background:#7f1d1d; color:#fff; }
.badge-curated { background:#4f46e5; color:#fff; }
.badge-enforced { background:#334155; color:#fff; }
.badge-inferred { background:#57534e; color:#fff; }
.theme-detail-card { background:var(--panel); padding:1rem 1.1rem; border:1px solid var(--border); border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,.25); }
.theme-detail-card h3 { margin-top:0; margin-bottom:.4rem; }
.theme-detail-card .desc { margin-top:0; font-size:13px; line-height:1.45; }
.theme-detail-card h4 { margin-bottom:.35rem; margin-top:.85rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.85; }
.breadcrumb { font-size:12px; margin-bottom:.4rem; }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
.card-meta .line + .line { margin-top:.35rem; }
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
footer.site-footer { flex-shrink: 0; }
/* Hide hover preview on narrow screens to avoid covering content */
@media (max-width: 900px){
.card-hover{ display: none !important; }
}
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
.card-hover .ov-chip { display:inline-block; background:#38bdf8; color:#102746; border:1px solid #0f3a57; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; font-weight:600; }
/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */
.card-hover .dual.two-faced img { width:320px; }
.card-hover .dual.two-faced { gap:8px; }
/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */
.card-hover .dual.combo img { width:300px; }
@media (max-width: 1100px){
.card-hover .dual.two-faced img { width:280px; }
.card-hover .dual.combo img { width:260px; }
}
/* Unified hover-card-panel styling parity */
#hover-card-panel.is-payoff { border-color: var(--accent, #38bdf8); box-shadow:0 6px 24px rgba(0,0,0,.65), 0 0 0 1px var(--accent, #38bdf8) inset; }
#hover-card-panel.is-payoff .hcp-img { border-color: var(--accent, #38bdf8); }
/* Inline theme/tag list styling (unifies legacy second panel) */
/* Two-column hover layout */
#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; }
#hover-card-panel .hcp-taglist li.overlap { font-weight:600; color:var(--accent,#38bdf8); }
#hover-card-panel .hcp-taglist li.overlap::before { content:'•'; color:var(--accent,#38bdf8); position:absolute; left:-10px; }
#hover-card-panel .hcp-overlaps { font-size:10px; line-height:1.25; margin-top:2px; }
#hover-card-panel .hcp-ov-chip { display:inline-flex; align-items:center; background:var(--accent,#38bdf8); color:#102746; border:1px solid rgba(10,54,82,.6); border-radius:9999px; padding:3px 10px; font-size:13px; margin-right:6px; margin-top:4px; font-weight:500; letter-spacing:.02em; }
/* Hide modal-specific close button outside modal host */
#preview-close-btn { display:none; }
#theme-preview-modal #preview-close-btn { display:inline-flex; }
/* Overlay flip toggle for double-faced cards */
.dfc-host { position:relative; }
.dfc-toggle { position:absolute; top:6px; left:6px; z-index:5; background:rgba(15,23,42,.82); color:#fff; border:1px solid #475569; border-radius:50%; width:36px; height:36px; padding:0; font-size:16px; cursor:pointer; line-height:1; display:flex; align-items:center; justify-content:center; opacity:.92; backdrop-filter: blur(3px); }
.dfc-toggle:hover, .dfc-toggle:focus { opacity:1; box-shadow:0 0 0 2px rgba(56,189,248,.35); outline:none; }
.dfc-toggle:active { transform: translateY(1px); }
.dfc-toggle .icon { font-size:12px; }
.dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); }
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
.dfc-toggle[aria-pressed='true'] { box-shadow:0 0 0 2px var(--accent, #38bdf8); }
.list-row .dfc-toggle { position:static; width:auto; height:auto; border-radius:6px; padding:2px 8px; font-size:12px; opacity:1; backdrop-filter:none; margin-left:4px; }
.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: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; }
#hover-card-panel.mobile .hcp-meta { font-size:13px; text-align:left; }
#hover-card-panel.mobile .hcp-overlaps { display:flex; flex-wrap:wrap; gap:6px; width:100%; }
#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { margin:0; }
#hover-card-panel.mobile .hcp-taglist { columns:1; display:flex; flex-wrap:wrap; gap:6px; margin:4px 0 2px; max-height:none; overflow:visible; padding:0; }
#hover-card-panel.mobile .hcp-taglist li { background:rgba(37,99,235,.18); border-radius:9999px; padding:4px 10px; display:inline-flex; align-items:center; }
#hover-card-panel.mobile .hcp-taglist li.overlap { background:rgba(37,99,235,.28); color:#dbeafe; }
#hover-card-panel.mobile .hcp-taglist li.overlap::before { display:none; }
#hover-card-panel.mobile .hcp-reasons { max-height:220px; width:100%; }
#hover-card-panel.mobile .hcp-tags { word-break:normal; white-space:normal; text-align:left; width:100%; font-size:12px; opacity:.7; }
#hover-card-panel .hcp-close { appearance:none; border:none; background:transparent; color:#9ca3af; font-size:18px; line-height:1; padding:2px 4px; cursor:pointer; border-radius:6px; display:none; }
#hover-card-panel .hcp-close:focus { outline:2px solid rgba(59,130,246,.6); outline-offset:2px; }
#hover-card-panel.mobile .hcp-close { display:inline-flex; }
/* Fade transition for hover panel image */
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
</style>
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
<style>
.nav a.active { font-weight:600; position:relative; }
.nav a.active::after { content:''; position:absolute; left:0; bottom:2px; width:100%; height:2px; background:var(--accent, #38bdf8); border-radius:2px; }
@ -358,25 +244,6 @@
setInterval(pollStatus, 10000);
pollStatus();
// Health indicator poller
var healthDot = document.getElementById('health-dot');
function renderHealth(data){
if (!healthDot) return;
var ok = data && data.status === 'ok';
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
}
function pollHealth(){
try {
fetch('/healthz', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderHealth)
.catch(function(){ renderHealth({ status: 'bad' }); });
} catch(e){ renderHealth({ status: 'bad' }); }
}
setInterval(pollHealth, 5000);
pollHealth();
function ensureCard() {
// Legacy large image hover kept for fallback; disabled in favor of unified hover-card-panel
if (window.__disableLegacyCardHover) return document.getElementById('card-hover') || document.createElement('div');
@ -416,17 +283,17 @@
function buildCardUrl(name, version, nocache, face){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (face === 'back') url += '&face=back';
if (nocache) url += '&t=' + Date.now();
var url = '/api/images/' + (version||'normal') + '/' + q;
if (face === 'back') url += '?face=back';
if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now();
return url;
}
// Generic Scryfall image URL builder
// Generic card image URL builder
function buildScryfallImageUrl(name, version, nocache){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (nocache) url += '&t=' + Date.now();
var url = '/api/images/' + (version||'normal') + '/' + q;
if (nocache) url += '?t=' + Date.now();
return url;
}
@ -624,9 +491,21 @@
}
function hasTwoFaces(card){
if(!card) return false;
// Check if card has a layout attribute - this is the source of truth
var layout = card.getAttribute('data-layout') || '';
if(layout) {
// Only these layouts are actual flippable double-faced cards
var flippableLayouts = ['modal_dfc', 'transform', 'reversible_card', 'flip', 'meld'];
return flippableLayouts.indexOf(layout) > -1;
}
// Fallback: If no layout data, check if name has // (backwards compatibility)
// This shouldn't happen if templates properly pass data-layout
var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name'));
return name.indexOf('//') > -1;
}
window.__dfcHasTwoFaces = hasTwoFaces; // Expose globally for popup hover panel
function keyFor(card){
var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, 'data-original-name') || '').toLowerCase();
return LS_PREFIX + nm;
@ -669,7 +548,9 @@
var face = card.getAttribute(FACE_ATTR) || 'front';
var btn = document.createElement('button');
btn.type='button';
btn.className='dfc-toggle';
// Mobile: flip in popup only (flex below md). Desktop: flip in thumbnails only (hidden at md+)
var inPopup = card.closest && card.closest('#hover-card-panel');
btn.className = inPopup ? 'dfc-toggle flex md:hidden' : 'dfc-toggle hidden md:flex';
btn.setAttribute('aria-pressed','false');
btn.setAttribute('tabindex','0');
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
@ -692,6 +573,7 @@
card.insertBefore(btn, card.firstChild);
}
}
window.__dfcEnsureButton = ensureButton; // Expose for hover panel use
function flip(card, btn){
var now = Date.now();
if(now - lastFlip < DEBOUNCE_MS) return;
@ -746,6 +628,7 @@
} catch(_) {}
})();
</script>
<script src="/static/components.js?v=20250121-1"></script>
<script src="/static/app.js?v=20250826-4"></script>
{% if enable_themes %}
<script>
@ -1210,13 +1093,25 @@
var chosenFace = card.getAttribute('data-current-face') || 'front';
lastCard = card;
function renderHoverFace(face){
var desiredVersion='large';
var faceParam = (face==='back') ? '&face=back' : '';
var desiredVersion='normal'; // Use 'normal' since we only cache small/normal
var currentKey = nm+':'+face+':'+desiredVersion;
var prevFace = imgEl.getAttribute('data-face');
var faceChanged = prevFace && prevFace !== face;
if(imgEl.getAttribute('data-current')!== currentKey){
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
// For DFC cards, extract the specific face name for cache lookup
// but also send face parameter for Scryfall fallback
var faceName = nm;
var isDFC = nm.indexOf('//')>-1;
if(isDFC){
var faces = nm.split('//');
faceName = (face==='back') ? faces[1].trim() : faces[0].trim();
}
// Use cache-aware API endpoint with the specific face name
// Add face parameter for DFC back faces to help Scryfall fallback
var src='/api/images/'+desiredVersion+'/'+encodeURIComponent(faceName);
if(isDFC && face==='back'){
src += '?face=back';
}
if(faceChanged){ imgEl.style.opacity = 0; }
prefetch(src);
imgEl.src = src;
@ -1228,12 +1123,50 @@
imgEl.__errBound = true;
imgEl.addEventListener('error', function(){
var cur = imgEl.getAttribute('src')||'';
if(cur.indexOf('version=large')>-1){ imgEl.src = cur.replace('version=large','version=normal'); }
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
// Fallback from normal to small if image fails to load
if(cur.indexOf('/api/images/normal/')>-1){
imgEl.src = cur.replace('/api/images/normal/','/api/images/small/');
}
});
}
}
renderHoverFace(chosenFace);
// Add DFC flip button to popup panel ONLY on mobile
var checkFlip = window.__dfcHasTwoFaces || function(){ return false; };
if(hasFlip && imgEl && checkFlip(card) && isMobileMode()){
var imgWrap = imgEl.parentElement; // .hcp-img-wrap
if(imgWrap && !imgWrap.querySelector('.dfc-toggle')){
// Create a custom flip button that flips the ORIGINAL card (lastCard)
// This ensures the popup refreshes with updated tags/themes
var flipBtn = document.createElement('button');
flipBtn.type = 'button';
flipBtn.className = 'dfc-toggle'; // No responsive classes needed - only created on mobile
flipBtn.setAttribute('aria-pressed', 'false');
flipBtn.setAttribute('tabindex', '0');
flipBtn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
// Flip the ORIGINAL card element, not the popup wrapper
flipBtn.addEventListener('click', function(ev){
ev.stopPropagation();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard); // This will trigger popup refresh
}
});
flipBtn.addEventListener('keydown', function(ev){
if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){
ev.preventDefault();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard);
}
}
});
imgWrap.classList.add('dfc-host');
imgWrap.appendChild(flipBtn);
}
}
window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null;
if(evt){ window.__lastPointerEvent = evt; }
if(isMobileMode()){
@ -1269,8 +1202,19 @@
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
// For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile)
if(el.closest && el.closest('.card-tile')){
var imgBtn = el.closest('.img-btn');
if(imgBtn) return imgBtn.closest('.card-tile');
// If directly on the image
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))){
return el.closest('.card-tile');
}
// Don't trigger on other parts of the tile (buttons, text, etc.)
return null;
}
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
if(container) return container;
// Image-based detection (any card image carrying data-card-name)
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){

View file

@ -1,13 +1,18 @@
{# Single card tile for grid display #}
<div class="card-browser-tile card-tile" data-card-name="{{ card.name }}" data-tags="{{ card.themeTags_parsed|join(', ') if card.themeTags_parsed else '' }}">
<div class="card-browser-tile card-tile"
data-card-name="{{ card.name }}"
data-tags="{{ card.themeTags_parsed|join(', ') if card.themeTags_parsed else '' }}"
{% if card.layout %}data-layout="{{ card.layout }}"{% endif %}>
{# Card image (uses hover system for preview) #}
<div class="card-browser-tile-image">
<img
loading="lazy"
decoding="async"
alt="{{ card.name }}"
src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
src="{{ card.name|card_image('normal') }}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
data-card-name="{{ card.name }}"
{% if card.layout %}data-layout="{{ card.layout }}"{% endif %}
/>
{# Fallback for missing images #}
<div style="display:none; width:100%; height:100%; align-items:center; justify-content:center; background:#1a1d24; color:#9ca3af; font-size:14px; padding:1rem; text-align:center; position:absolute; top:0; left:0;">

View file

@ -235,7 +235,7 @@
<div class="similar-card-tile card-tile" data-card-name="{{ card.name }}">
<!-- Card Image (uses hover system for preview) -->
<div class="similar-card-image">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
<img src="{{ card.name|card_image('normal') }}"
alt="{{ card.name }}"
loading="lazy"
data-card-name="{{ card.name }}"

View file

@ -193,7 +193,7 @@
<div class="card-detail-header">
<!-- Card Image (no hover on detail page) -->
<div class="card-image-large">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
<img src="{{ card.name|card_image('normal') }}"
alt="{{ card.name }}"
loading="lazy"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">

View file

@ -32,13 +32,108 @@
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
{% if it.owned %}data-owned="1"{% endif %}
data-tags="{{ tags|join(', ') }}" hx-post="/build/replace"
data-tags="{{ tags|join(', ') }}"
hx-post="/build/replace"
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">
Replace with {{ it.name }}
hx-target="closest .alts"
hx-swap="outerHTML"
title="Lock this alternative and unlock the current pick">
{{ it.name }}
</button>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<script>
// Mobile: tap to preview, tap "Use as Replacement" button in popup to replace
(function(){
var altPanel = document.currentScript.previousElementSibling;
if(!altPanel) return;
// Track which button triggered the popup
var pendingReplacement = null;
// Better mobile detection (matches base.html logic)
function isMobileMode(){
var coarseQuery = window.matchMedia('(pointer: coarse)');
return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768;
}
// Intercept htmx request before it's sent
altPanel.addEventListener('htmx:configRequest', function(e){
var btn = e.detail.elt;
if(!btn || !btn.classList.contains('alt-option')) return;
if(isMobileMode() && !btn.dataset.mobileConfirmed){
// First tap on mobile: cancel the request, show preview instead
e.preventDefault();
pendingReplacement = btn;
// Show card preview and inject replacement button
if(window.__hoverShowCard){
window.__hoverShowCard(btn);
// Inject "Use as Replacement" button into popup
setTimeout(function(){
if(!isMobileMode()) return; // Double-check we're still in mobile mode
var hoverPanel = document.getElementById('hover-card-panel');
if(hoverPanel && !hoverPanel.querySelector('.mobile-replace-btn')){
var imgWrap = hoverPanel.querySelector('.hcp-img-wrap');
if(imgWrap){
var replaceBtn = document.createElement('button');
replaceBtn.type = 'button';
replaceBtn.className = 'btn mobile-replace-btn';
replaceBtn.textContent = 'Use as Replacement';
replaceBtn.style.cssText = 'width:100%;padding:0.75rem;font-size:15px;margin-top:8px;pointer-events:auto;position:relative;z-index:10000;';
var handleClick = function(ev){
ev.preventDefault();
ev.stopPropagation();
if(!pendingReplacement) return;
pendingReplacement.dataset.mobileConfirmed = '1';
pendingReplacement.click();
// Close the popup after a short delay
setTimeout(function(){
var closeBtn = hoverPanel.querySelector('.hcp-close');
if(closeBtn) closeBtn.click();
}, 100);
};
replaceBtn.onclick = handleClick;
replaceBtn.addEventListener('click', handleClick);
replaceBtn.addEventListener('touchend', handleClick);
imgWrap.appendChild(replaceBtn);
}
}
}, 100);
}
return false;
}
// Desktop or mobile with confirmation: allow request to proceed
if(btn.dataset.mobileConfirmed){
btn.removeAttribute('data-mobileConfirmed');
pendingReplacement = null;
}
});
// Reset pending replacement when popup closes
document.addEventListener('click', function(e){
if(e.target.closest('.hcp-close')){
pendingReplacement = null;
// Remove mobile replace button when closing
var hoverPanel = document.getElementById('hover-card-panel');
if(hoverPanel){
var replaceBtn = hoverPanel.querySelector('.mobile-replace-btn');
if(replaceBtn) replaceBtn.remove();
}
}
});
})();
</script>

View file

@ -29,8 +29,8 @@
{% set sev = (f.severity or 'FAIL')|upper %}
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>
<a href="https://scryfall.com/search?q={{ f.name|urlencode }}" target="_blank" rel="noopener" class="img-btn" title="{{ f.name }}">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=large 672w"
<img class="card-thumb" src="{{ f.name|card_image('normal') }}" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
srcset="{{ f.name|card_image('small') }} 160w, {{ f.name|card_image('normal') }} 488w"
sizes="160px" />
</a>
<div class="owned-badge" title="{{ 'Owned' if f.owned else 'Not owned' }}" aria-label="{{ 'Owned' if f.owned else 'Not owned' }}">{% if f.owned %}✔{% else %}✖{% endif %}</div>

View file

@ -1,22 +1,22 @@
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
<div class="modal-backdrop" style="position:fixed; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
<div class="modal modal-overlay" id="new-deck-modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="newDeckTitle">Build a New Deck</h3>
</div>
{% if error %}
<div class="error" role="alert" style="margin:.35rem 0 .5rem 0;">{{ error }}</div>
<div class="error my-2" role="alert">{{ error }}</div>
{% endif %}
<form hx-post="/build/new" hx-target="#wizard" hx-swap="innerHTML" hx-on="htmx:afterRequest: (function(evt){ try{ if(evt && evt.detail && evt.detail.elt === this){ var m=this.closest('.modal'); if(m){ m.remove(); } } }catch(_){} }).call(this, event)" autocomplete="off">
<fieldset>
<legend>Basics</legend>
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
<div class="basics-grid grid grid-cols-[2fr_1fr] gap-4 items-start">
<div>
<label style="display:block; margin-bottom:.5rem;">
<label class="form-label">
<span class="muted">Optional name (used for file names)</span>
<input type="text" name="name" placeholder="e.g., Inti Discard Tempo" autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label style="display:block; margin-bottom:.5rem;">
<label class="form-label">
<span>Commander</span>
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
@ -24,11 +24,11 @@
data-hx-debounce="220" data-hx-debounce-events="input"
data-hx-debounce-flush="blur" />
</label>
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>
<small class="muted block mt-1">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted text-xs min-h-[1.1em]"></div>
</div>
<div id="newdeck-commander-slot" class="muted" style="max-width:230px;">
<em style="font-size:12px;">Pick a commander to preview here.</em>
<div id="newdeck-commander-slot" class="muted max-w-[230px]">
<em class="text-xs">Pick a commander to preview here.</em>
</div>
</div>
</fieldset>
@ -45,11 +45,11 @@
<input type="hidden" name="tag_mode" value="AND" />
{% endif %}
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
<div id="newdeck-multicopy-slot" class="muted mt-2 min-h-[1rem]"></div>
{% if enable_custom_themes %}
{% include "build/_new_deck_additional_themes.html" %}
{% endif %}
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
<div class="mt-2" id="newdeck-bracket-slot">
<label>Bracket
<select name="bracket">
{% for b in brackets %}
@ -63,47 +63,47 @@
</fieldset>
<fieldset>
<legend>Preferences</legend>
<div style="text-align: left;">
<div style="margin-bottom: 1rem; display:flex; flex-direction:column; gap:0.75rem;">
<label for="pref-combos-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_combos %}checked{% endif %} />
<div class="text-left">
<div class="mb-4 flex flex-col gap-3">
<label for="pref-combos-chk" class="form-checkbox-label" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" {% if form and form.prefer_combos %}checked{% endif %} />
<span>Prioritize combos</span>
</label>
<div id="pref-combos-config" style="margin-top: 0.5rem; margin-left: 1.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 8px; display: none;">
<div style="display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;">
<div id="pref-combos-config" class="mt-2 ml-6 p-2 border border-[var(--border)] rounded-lg hidden">
<div class="flex gap-4 items-center flex-wrap">
<label>
<span>How many combos?</span>
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" style="width: 6rem; margin-left: 0.5rem;" />
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" class="w-24 ml-2" />
</label>
<div>
<div class="muted" style="font-size: 12px; margin-bottom: 0.25rem;">Balance of early vs late-game</div>
<label style="display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.5rem;">
<div class="muted text-xs mb-1">Balance of early vs late-game</div>
<label class="inline-flex items-center gap-1 mr-2">
<input type="radio" name="combo_balance" value="early" {% if form and form.combo_balance == 'early' %}checked{% endif %} /> Early
</label>
<label style="display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.5rem;">
<label class="inline-flex items-center gap-1 mr-2">
<input type="radio" name="combo_balance" value="late" {% if form and form.combo_balance == 'late' %}checked{% endif %} /> Late
</label>
<label style="display: inline-flex; align-items: center; gap: 0.25rem;">
<label class="inline-flex items-center gap-1">
<input type="radio" name="combo_balance" value="mix" {% if not form or (form and (not form.combo_balance or form.combo_balance == 'mix')) %}checked{% endif %} /> Mix
</label>
</div>
</div>
</div>
<label for="pref-mc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.enable_multicopy %}checked{% endif %} />
<label for="pref-mc-chk" class="form-checkbox-label" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" {% if form and form.enable_multicopy %}checked{% endif %} />
<span>Enable Multi-Copy package</span>
</label>
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.75rem;">
<label for="use-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.use_owned_only %}checked{% endif %} />
<div class="flex flex-col gap-2 mt-3">
<label for="use-owned-chk" class="form-checkbox-label" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" {% if form and form.use_owned_only %}checked{% endif %} />
<span>Use only owned cards</span>
</label>
<label for="prefer-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_owned %}checked{% endif %} />
<label for="prefer-owned-chk" class="form-checkbox-label" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" {% if form and form.prefer_owned %}checked{% endif %} />
<span>Prefer owned cards (allow unowned fallback)</span>
</label>
<label for="swap-mdfc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
<label for="swap-mdfc-chk" class="form-checkbox-label" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
<span>Swap basics for MDFC lands</span>
</label>
</div>
@ -114,101 +114,97 @@
{% if allow_must_haves %}
<fieldset>
<legend>Include/Exclude Cards</legend>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:.5rem;" class="include-exclude-grid">
<div class="include-exclude-grid">
<!-- Include Cards Column (Left, Green) -->
<div>
<label style="display:block; margin-bottom:.5rem;">
<span style="color: #4ade80; font-weight: 500;">✓ Must Include Cards</span>
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Cards that must appear in your deck</small>
<label class="card-list-label">
<span class="card-list-label-include">✓ Must Include Cards</span>
<small class="muted block text-xs mt-1">Cards that must appear in your deck</small>
</label>
<textarea name="include_cards" id="include_cards_textarea"
placeholder="Lightning Bolt&#10;Counterspell&#10;Swords to Plowshares"
style="width:100%; min-height:60px; resize:vertical; font-family:monospace; font-size:12px; border-left: 3px solid #4ade80;"
class="include-textarea"
autocomplete="off" autocapitalize="off" spellcheck="false">{{ form.include_cards if form and form.include_cards else '' }}</textarea>
<!-- Include Cards Chips Container -->
<div id="include_chips_container" style="margin-top:.5rem; min-height:30px; border:1px solid #4ade80; border-radius:6px; padding:.5rem; background:rgba(74, 222, 128, 0.05); display:flex; flex-wrap:wrap; gap:.25rem; align-items:flex-start;">
<div id="include_chips" style="display:flex; flex-wrap:wrap; gap:.25rem; flex:1;"></div>
<div style="color:#6b7280; font-size:11px; font-style:italic;" id="include_chips_placeholder">Enter card names above to see them as removable tags</div>
<div id="include_chips_container" class="include-chips-container">
<div id="include_chips" class="chips-inner"></div>
<div class="chips-placeholder" id="include_chips_placeholder">Enter card names above to see them as removable tags</div>
</div>
<div style="display:flex; align-items:center; gap:.5rem; margin-top:.5rem; font-size:12px;">
<label for="include_file_upload" class="btn" style="cursor:pointer; font-size:11px; padding:.25rem .5rem; background:#065f46; border-color:#059669;">
<div class="card-list-controls">
<label for="include_file_upload" class="btn btn-upload-include">
📄 Upload .txt
</label>
<input type="file" id="include_file_upload" accept=".txt" style="display:none;"
<input type="file" id="include_file_upload" accept=".txt" class="hidden"
onchange="handleIncludeFileUpload(this)" />
<button type="button" onclick="clearIncludes()" class="btn" style="font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
<button type="button" onclick="clearIncludes()" class="btn btn-clear">
🗑 Clear All
</button>
<div id="include_count" class="muted" style="font-size:11px;">0/10</div>
<div id="include_badges" style="display:flex; gap:.25rem; font-size:10px;"></div>
<div id="include_count" class="muted card-list-count">0/10</div>
<div id="include_badges" class="card-list-badges"></div>
</div>
<div id="include_validation" style="margin-top:.5rem; font-size:12px;"></div>
<div id="include_validation" class="card-list-validation"></div>
</div>
<!-- Exclude Cards Column (Right, Red) -->
<div>
<label style="display:block; margin-bottom:.5rem;">
<span style="color: #ef4444; font-weight: 500;">✗ Must Exclude Cards</span>
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Cards to avoid in your deck</small>
<label class="card-list-label">
<span class="card-list-label-exclude">✗ Must Exclude Cards</span>
<small class="muted block text-xs mt-1">Cards to avoid in your deck</small>
</label>
<textarea name="exclude_cards" id="exclude_cards_textarea"
placeholder="Sol Ring&#10;Rhystic Study&#10;Smothering Tithe"
style="width:100%; min-height:60px; resize:vertical; font-family:monospace; font-size:12px; border-left: 3px solid #ef4444;"
class="exclude-textarea"
autocomplete="off" autocapitalize="off" spellcheck="false">{{ form.exclude_cards if form and form.exclude_cards else '' }}</textarea>
<!-- Exclude Cards Chips Container -->
<div id="exclude_chips_container" style="margin-top:.5rem; min-height:30px; border:1px solid #ef4444; border-radius:6px; padding:.5rem; background:rgba(239, 68, 68, 0.05); display:flex; flex-wrap:wrap; gap:.25rem; align-items:flex-start;">
<div id="exclude_chips" style="display:flex; flex-wrap:wrap; gap:.25rem; flex:1;"></div>
<div style="color:#6b7280; font-size:11px; font-style:italic;" id="exclude_chips_placeholder">Enter card names above to see them as removable tags</div>
<div id="exclude_chips_container" class="exclude-chips-container">
<div id="exclude_chips" class="chips-inner"></div>
<div class="chips-placeholder" id="exclude_chips_placeholder">Enter card names above to see them as removable tags</div>
</div>
<div style="display:flex; align-items:center; gap:.5rem; margin-top:.5rem; font-size:12px;">
<label for="exclude_file_upload" class="btn" style="cursor:pointer; font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
<div class="card-list-controls">
<label for="exclude_file_upload" class="btn btn-upload-exclude">
📄 Upload .txt
</label>
<input type="file" id="exclude_file_upload" accept=".txt" style="display:none;"
<input type="file" id="exclude_file_upload" accept=".txt" class="hidden"
onchange="handleExcludeFileUpload(this)" />
<button type="button" onclick="clearExcludes()" class="btn" style="font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
<button type="button" onclick="clearExcludes()" class="btn btn-clear">
🗑 Clear All
</button>
<div id="exclude_count" class="muted" style="font-size:11px;">0/15</div>
<div id="exclude_badges" style="display:flex; gap:.25rem; font-size:10px;"></div>
<div id="exclude_count" class="muted card-list-count">0/15</div>
<div id="exclude_badges" class="card-list-badges"></div>
</div>
<div id="exclude_validation" style="margin-top:.5rem; font-size:12px;"></div>
<div id="exclude_validation" class="card-list-validation"></div>
</div>
</div>
<div style="margin-top:.75rem; padding:.5rem; background:rgba(59, 130, 246, 0.1); border:1px solid rgba(59, 130, 246, 0.3); border-radius:6px;">
<div class="info-panel">
<details>
<summary style="cursor:pointer; font-size:12px; color:#60a5fa;">Advanced Options</summary>
<div style="margin-top:.5rem; font-size:12px; line-height:1.5;">
<div style="margin-bottom:.5rem;">
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
<input type="radio" name="enforcement_mode" value="warn" {% if not form or (form and (not form.enforcement_mode or form.enforcement_mode == 'warn')) %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
<span>Warn mode</span>
<small class="muted" style="margin-left:.25rem;">(proceed if cards missing)</small>
<summary>Advanced Options</summary>
<div class="info-panel-content">
<div class="mb-2">
<label class="form-checkbox-label">
<input type="radio" name="enforcement_mode" value="warn" {% if not form or (form and (not form.enforcement_mode or form.enforcement_mode == 'warn')) %}checked{% endif %} />
<span>Warn mode <small class="muted ml-1">(proceed if cards missing)</small></span>
</label>
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
<input type="radio" name="enforcement_mode" value="strict" {% if form and form.enforcement_mode == 'strict' %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
<span>Strict mode</span>
<small class="muted" style="margin-left:.25rem;">(abort if cards missing)</small>
<label class="form-checkbox-label">
<input type="radio" name="enforcement_mode" value="strict" {% if form and form.enforcement_mode == 'strict' %}checked{% endif %} />
<span>Strict mode <small class="muted ml-1">(abort if cards missing)</small></span>
</label>
</div>
<div>
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
<input type="checkbox" name="allow_illegal" {% if form and form.allow_illegal %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
<label class="form-checkbox-label">
<input type="checkbox" name="allow_illegal" {% if form and form.allow_illegal %}checked{% endif %} />
<span>Allow illegal cards</span>
</label>
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
<input type="checkbox" name="fuzzy_matching" {% if not form or (form and (form.fuzzy_matching is none or form.fuzzy_matching)) %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
<span>Fuzzy name matching</span>
</label>
<!-- Fuzzy matching always enabled - hidden field -->
<input type="hidden" name="fuzzy_matching" value="1" />
</div>
</div>
</details>
</div>
<small class="muted" style="display:block; margin-top:.5rem; font-size:11px; text-align:center;">
Enter one card name per line. Cards are validated against the database with smart name matching.
<small class="muted block mt-2 text-xs text-center">
Enter one card name per line. Cards are validated with fuzzy name matching.
</small>
</fieldset>
{% if not show_must_have_buttons %}
<div class="muted" style="font-size:12px; margin-top:.75rem;">
<div class="muted text-xs mt-3">
Step 5 quick-add buttons are hidden (<code>SHOW_MUST_HAVE_BUTTONS=0</code>), but you can still seed must include/exclude lists here.
</div>
{% endif %}
@ -217,22 +213,22 @@
{% if enable_batch_build %}
<fieldset>
<legend>Build Options</legend>
<div style="display:flex; flex-direction:column; gap:0.75rem;">
<label style="display:block;">
<div class="flex flex-col gap-3">
<label class="block">
<span>Number of decks to build</span>
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Run the same configuration multiple times to see variance in results</small>
<small class="muted block text-xs mt-1">Run the same configuration multiple times to see variance in results</small>
</label>
{% if ideals_ui_mode == 'slider' %}
<div style="display:flex; align-items:center; gap:1rem;">
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="flex:1;"
<div class="flex items-center gap-4">
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" class="flex-1"
oninput="document.getElementById('build_count_value').textContent = this.value; updateBuildCountLabel(this.value); updateButtonState(this.value);" />
<span id="build_count_value" style="min-width:2.5rem; text-align:center; font-weight:500; font-size:1.1em;">{{ form.build_count if form and form.build_count else 1 }}</span>
<span id="build_count_value" class="min-w-[2.5rem] text-center font-medium text-lg">{{ form.build_count if form and form.build_count else 1 }}</span>
</div>
<small id="build_count_label" class="muted" style="font-size:11px; text-align:center;">Build 1 deck (normal build)</small>
<small id="build_count_label" class="muted text-xs text-center">Build 1 deck (normal build)</small>
{% else %}
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="width:6rem;"
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" class="w-24"
oninput="updateButtonState(this.value);" />
<small class="muted" style="font-size:11px;">Enter 1 for normal build, 2-10 to compare multiple results</small>
<small class="muted text-xs">Enter 1 for normal build, 2-10 to compare multiple results</small>
{% endif %}
</div>
</fieldset>
@ -240,9 +236,9 @@
{# Hidden input to always send build_count=1 when feature disabled #}
<input type="hidden" name="build_count" value="1" />
{% endif %}
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
<div class="modal-footer">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<div style="display:flex; gap:.5rem;">
<div class="modal-footer-left">
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
<button type="submit" class="btn-continue" id="create-btn">Create</button>
</div>
@ -794,7 +790,8 @@
formData.append('commander', commander);
formData.append('enforcement_mode', enforcementMode ? enforcementMode.value : 'warn');
formData.append('allow_illegal', allowIllegal ? allowIllegal.checked : false);
formData.append('fuzzy_matching', fuzzyMatching ? fuzzyMatching.checked : true);
// For hidden input, use .value instead of .checked (hidden inputs don't have checked property)
formData.append('fuzzy_matching', fuzzyMatching ? (fuzzyMatching.value || 'true') : 'true');
console.log('Making fetch request to /build/validate/include_exclude');
fetch('/build/validate/include_exclude', {
@ -1536,11 +1533,32 @@
<style>
/* Modal responsive tweaks (scoped) */
@media (max-width: 720px){
@media (max-width: 600px){
.modal .basics-grid{ grid-template-columns: 1fr !important; }
#newdeck-commander-slot{ max-width: 100% !important; }
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
.modal .modal-content{ width: min(95vw, 560px) !important; }
}
/* Keep grid layout on tablet and desktop */
@media (min-width: 601px){
.modal .basics-grid{
display: grid !important;
grid-template-columns: 2fr 1fr !important;
}
}
</style>
<script>
// Remove any existing modal to prevent duplicates
(function() {
// Find all modals with the same ID
var allModals = document.querySelectorAll('#new-deck-modal');
// If there's more than one, remove all but the last (newest) one
if (allModals.length > 1) {
for (var i = 0; i < allModals.length - 1; i++) {
allModals[i].remove();
}
}
})();
</script>

View file

@ -15,15 +15,15 @@
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ pname|urlencode }}&format=image&version=normal" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
<img src="{{ pname|card_image('normal') }}" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
</a>
</aside>
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px; word-wrap:break-word; overflow-wrap:break-word;">{{ pname }}</div>
{% if partner_preview_payload %}
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
{% if not partner_image_url and partner_secondary_name %}
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %}
{% set partner_image_url = partner_secondary_name|card_image('normal') %}
{% endif %}
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
{% if not partner_href and partner_secondary_name %}
@ -224,36 +224,83 @@
});
}
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
function updatePartnerRecommendations(tags){
if (!reco) return;
Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
var unique = [];
function updatePartnerTags(partnerTags){
if (!list || !reco) return;
// Remove old partner-added chips from available list
Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); });
// Deduplicate: remove partner tags from recommended section to avoid showing them twice
if (partnerTags && partnerTags.length > 0) {
var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
var tag = btn.dataset.tag || '';
if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
btn.remove();
}
});
}
// Get existing tags from the available list (original server-rendered ones)
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
return {
element: b,
tag: (b.dataset.tag || '').trim(),
tagLower: (b.dataset.tag || '').trim().toLowerCase()
};
});
// Build combined list: existing + new partner tags
var combined = [];
var seen = new Set();
(Array.isArray(tags) ? tags : []).forEach(function(tag){
// Add existing tags first
existingTags.forEach(function(item){
if (!item.tag || seen.has(item.tagLower)) return;
seen.add(item.tagLower);
combined.push({ tag: item.tag, element: item.element, isPartner: false });
});
// Add new partner tags
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
unique.push(value);
combined.push({ tag: value, element: null, isPartner: true });
});
var insertBefore = selAll && selAll.parentElement === reco ? selAll : null;
unique.forEach(function(tag){
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip chip-reco partner-suggestion';
btn.dataset.tag = tag;
btn.title = 'Synergizes with selected partner pairing';
btn.textContent = '★ ' + tag;
if (insertBefore){ reco.insertBefore(btn, insertBefore); }
else { reco.appendChild(btn); }
// Sort alphabetically
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
// Re-render the list in sorted order
list.innerHTML = '';
combined.forEach(function(item){
if (item.element) {
// Re-append existing element
list.appendChild(item.element);
} else {
// Create new partner-added chip
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip partner-added';
btn.dataset.tag = item.tag;
btn.title = 'From combined partner themes';
btn.textContent = item.tag;
list.appendChild(btn);
}
});
var hasAny = reco.querySelectorAll('button.chip-reco').length > 0;
// Update visibility of recommended section
var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0;
if (recoBlock){
recoBlock.style.display = hasAny ? '' : 'none';
recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0');
recoBlock.style.display = hasAnyReco ? '' : 'none';
recoBlock.setAttribute('data-has-reco', hasAnyReco ? '1' : '0');
}
if (selAll){ selAll.style.display = hasAny ? '' : 'none'; }
if (selAll){ selAll.style.display = hasAnyReco ? '' : 'none'; }
updateUI();
}
@ -264,11 +311,11 @@
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
tags = detail.payload.theme_tags;
}
updatePartnerRecommendations(tags);
updatePartnerTags(tags);
});
var initialPartnerTags = readPartnerPreviewTags();
updatePartnerRecommendations(initialPartnerTags);
updatePartnerTags(initialPartnerTags);
updateUI();
})();
</script>

View file

@ -106,7 +106,7 @@
{% if partner_preview %}
{% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %}
{% if not preview_image and partner_preview.secondary_name %}
{% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %}
{% set preview_image = partner_preview.secondary_name|card_image('normal') %}
{% endif %}
{% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %}
{% if not preview_href and partner_preview.secondary_name %}
@ -463,7 +463,7 @@
};
function buildCardImageUrl(name){
if (!name) return '';
return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal';
return '/api/images/normal/' + encodeURIComponent(name);
}
function buildScryfallUrl(name){
if (!name) return '';
@ -528,7 +528,9 @@
var colorLabel = payload.color_label || '';
var secondaryName = payload.secondary_name || payload.name || '';
var primary = payload.primary_name || primaryName;
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : [];
// Ensure theme_tags is always an array, even if it comes as a string or other type
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags :
(typeof payload.theme_tags === 'string' ? payload.theme_tags.split(',').map(function(t){ return t.trim(); }).filter(Boolean) : []);
var imageUrl = payload.secondary_image_url || payload.image_url || '';
if (!imageUrl && secondaryName){
imageUrl = buildCardImageUrl(secondaryName);

View file

@ -39,7 +39,7 @@
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ name }}" />
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
<img src="{{ name|card_image('normal') }}" data-card-name="{{ name }}"
alt="{{ name }}" loading="lazy" decoding="async" />
</button>
</form>
@ -77,7 +77,7 @@
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
{% set sel_base = (selected.split(' - Synergy (')[0] if ' - Synergy (' in selected else selected) %}
<a href="https://scryfall.com/search?q={{ sel_base|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ sel_base|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ sel_base }}" />
<img src="{{ sel_base|card_image('normal') }}" alt="{{ selected }} card image" data-card-name="{{ sel_base }}" />
</a>
</aside>
<div class="grow">

View file

@ -6,7 +6,7 @@
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
{% set commander_base = (commander.name.split(' - Synergy (')[0] if ' - Synergy (' in commander.name else commander.name) %}
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
</a>
</aside>
{% if partner_preview_payload %}
@ -22,7 +22,7 @@
{% set partner_name_base = partner_secondary_name %}
{% endif %}
{% if not partner_image_url and partner_name_base %}
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=normal' %}
{% set partner_image_url = partner_name_base|card_image('normal') %}
{% endif %}
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
{% if not partner_href and partner_name_base %}
@ -35,14 +35,14 @@
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
{% if partner_name_base %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
<img src="{{ partner_name_base|card_image('normal') }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
width="320"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
srcset="{{ partner_name_base|card_image('small') }} 160w, {{ partner_name_base|card_image('normal') }} 488w"
sizes="(max-width: 900px) 100vw, 320px" />
{% else %}
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />

View file

@ -5,7 +5,7 @@
{# Ensure synergy annotation suffix is stripped for Scryfall query and image fuzzy param #}
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
</a>
</aside>
<div class="grow" data-skeleton>

View file

@ -0,0 +1,32 @@
<section>
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div style="text-align:center; padding:3rem 1rem;">
<div class="spinner" style="margin:0 auto 1rem; width:48px; height:48px; border:4px solid rgba(0,0,0,0.1); border-top-color:#007bff; border-radius:50%; animation:spin 0.8s linear infinite;"></div>
<h3 style="margin:0 0 0.5rem;">Automating choices...</h3>
<p class="muted" style="margin:0;">{{ automation_message }}</p>
</div>
{# Hidden form that auto-submits with defaults #}
<form id="auto-step3-form" hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML" hx-trigger="load delay:100ms" style="display:none;">
{% for key, value in defaults.items() %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
</form>
</div>
</div>
</section>
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View file

@ -5,7 +5,7 @@
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
</a>
</aside>
<div class="grow" data-skeleton>

View file

@ -35,7 +35,7 @@
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image"
width="320"
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"
@ -45,10 +45,10 @@
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=large 672w"
srcset="{{ commander_base|card_image('small') }} 160w, {{ commander_base|card_image('normal') }} 488w"
sizes="(max-width: 900px) 100vw, 320px" />
</div>
<div class="muted" style="margin-top:.25rem;">
<div class="muted mt-1">
Commander: <span data-card-name="{{ commander }}"
data-original-name="{{ commander }}"
data-role="{{ commander_role_label or 'Commander' }}"
@ -79,51 +79,51 @@
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
{% if partner_name_base %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
<img src="{{ partner_name_base|card_image('normal') }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
width="320"
data-card-name="{{ partner_name_base }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
srcset="{{ partner_name_base|card_image('small') }} 160w, {{ partner_name_base|card_image('normal') }} 488w"
sizes="(max-width: 900px) 100vw, 320px" />
{% else %}
<img src="{{ combined.secondary_image_url or combined.image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
{% endif %}
{% if partner_href %}</a>{% endif %}
</div>
<div class="muted partner-label" style="margin-top:.35rem;">
<div class="muted partner-label mt-1.5">
{{ partner_role_label }}:
<span data-card-name="{{ partner_secondary_name }}"
data-original-name="{{ partner_secondary_name }}"
data-role="{{ partner_role_label }}"
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
</div>
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
<div class="muted partner-meta text-xs mt-1">
Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
</div>
{% if combined.color_label %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
<div class="muted partner-meta text-xs mt-1">
Colors: {{ combined.color_label }}
</div>
{% endif %}
{% if partner_theme_tags %}
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
<div class="muted partner-meta text-xs mt-1">
Theme emphasis: {{ partner_theme_tags|join(', ') }}
</div>
{% endif %}
{% endif %}
{% if status and status.startswith('Build complete') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
<div class="mt-3 flex gap-1.5 flex-wrap">
{% if csv_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<form action="/files" method="get" target="_blank" class="inline m-0">
<input type="hidden" name="path" value="{{ csv_path }}" />
<button type="submit">Download CSV</button>
</form>
{% endif %}
{% if txt_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<form action="/files" method="get" target="_blank" class="inline m-0">
<input type="hidden" name="path" value="{{ txt_path }}" />
<button type="submit">Download TXT</button>
</form>
@ -150,64 +150,64 @@
{% endif %}
</p>
{% if show_color_identity %}
<div class="muted" style="display:flex; align-items:center; gap:.35rem; margin:-.35rem 0 .5rem 0;">
<div class="muted flex items-center gap-1.5 -my-1.5 mb-2">
{{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }}
<span>{{ color_label }}</span>
</div>
{% endif %}
<p>Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}</p>
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<div class="my-1.5 text-muted flex gap-2 items-center flex-wrap">
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
<div class="flex items-center gap-4 flex-wrap">
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" class="bg-gray-700 text-gray-200 border-0 rounded-md px-2 py-1 cursor-pointer text-xs" title="Change owned settings in Review">Edit in Review</button>
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
</div>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
<span class="ml-auto"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
</div>
<p>Bracket: {{ bracket }}</p>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
<div class="flex items-center gap-2 flex-wrap my-1 mb-2">
{% if i and n %}
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
{% endif %}
{% set deck_count = (total_cards if total_cards is not none else 0) %}
<span class="chip"><span class="dot" style="background: var(--green-main);"></span> Deck {{ deck_count }}/100</span>
<span class="chip"><span class="dot dot-green"></span> Deck {{ deck_count }}/100</span>
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
<span class="chip"><span class="dot dot-blue"></span> Added {{ added_total }}</span>
{% endif %}
{% if prefer_combos %}
<span class="chip" title="Combos plan"><span class="dot" style="background: var(--orange-main);"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
<span class="chip" title="Combos plan"><span class="dot dot-orange"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
{% endif %}
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
<span class="chip" title="Trimmed overflow from this stage"><span class="dot dot-red"></span> Clamped {{ clamped_overflow }}</span>
{% endif %}
{% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %}
<span class="chip" title="Multi-Copy package summary"><span class="dot" style="background: var(--purple-main);"></span> {{ mc_summary }}</span>
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
{% endif %}
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
<button type="button" class="btn ml-auto" title="Copy permalink"
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
</div>
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
{% set pct_clamped = (pct if pct <= 100 else 100) %}
{% set pct_int = pct_clamped|int %}
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %}" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" style="margin:.25rem 0 1rem 0;" data-pct="{{ pct_int }}">
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %} my-1 mb-4" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" data-pct="{{ pct_int }}">
<div class="bar"></div>
</div>
{% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %}
<div class="muted" style="margin:.35rem 0 .25rem 0;">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
<div class="muted my-1">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
{% endif %}
{% if status %}
<div style="margin-top:1rem;">
<div class="mt-4">
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
</div>
{% endif %}
{% if gated and (not status or not status.startswith('Build complete')) %}
<div class="alert" style="margin-top:.5rem; color:#fecaca; background:#7f1d1d; border:1px solid #991b1b; padding:.5rem .75rem; border-radius:8px;">
<div class="alert-error">
Compliance gating active — resolve violations above (replace or remove cards) to continue.
</div>
{% endif %}
@ -220,15 +220,15 @@
{% if locked_cards is defined and locked_cards %}
{% from 'partials/_macros.html' import lock_button %}
<details id="locked-section" style="margin-top:.5rem;">
<details id="locked-section" class="mt-2">
<summary>Locked cards (always kept)</summary>
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
<ul id="locked-list" class="locked-list">
{% for lk in locked_cards %}
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<li class="locked-item">
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
<div class="lock-box" style="display:inline; margin-left:auto;">
<div class="lock-box-inline">
{{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
</div>
</li>
@ -238,7 +238,7 @@
{% endif %}
<!-- Last action chip (oob-updated) -->
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
<!-- Filters toolbar -->
<div class="cards-toolbar">
@ -248,10 +248,10 @@
<option value="owned">Owned</option>
<option value="not">Not owned</option>
</select>
<label style="display:flex;align-items:center;gap:.35rem;">
<label class="form-label-icon">
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
</label>
<label style="display:flex;align-items:center;gap:.35rem;">
<label class="form-label-icon">
<input type="checkbox" name="collapse_groups" data-pref="cards:collapse" /> Collapse groups
</label>
<select name="filter_sort" data-pref="cards:sort" aria-label="Sort">
@ -271,37 +271,37 @@
</div>
<!-- Sticky build controls on mobile -->
<div class="build-controls" style="position:sticky; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<div class="build-controls">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
</form>
<span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle">
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
<label class="muted form-label-icon" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
Replace stage picks
</label>
</form>
</div>
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
</form>
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
</form>
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
<label class="muted form-label-icon ml-2">
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
Show skipped stages
@ -311,14 +311,14 @@
{% if added_cards is not none %}
{% if history is defined and history %}
<details style="margin-top:.5rem;">
<details class="mt-2">
<summary>Stage timeline</summary>
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
<div class="muted text-xs my-1">Jump back to a previous stage, then you can continue forward again.</div>
<ul class="timeline-list">
{% for h in history %}
<li style="display:flex; align-items:center; gap:.5rem;">
<li class="timeline-item">
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" class="inline-form m-0">
<input type="hidden" name="to" value="{{ h.i }}" />
<button type="submit" class="btn">Go</button>
</form>
@ -327,13 +327,13 @@
</ul>
</details>
{% endif %}
<h4 style="margin-top:1rem;">Cards added this stage</h4>
<h4 class="mt-4">Cards added this stage</h4>
{% if skipped and (not added_cards or added_cards|length == 0) %}
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
<div class="muted my-2">No cards added in this stage.</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Owned</span>
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Not owned</span>
<div class="muted text-xs my-1 flex gap-3 items-center flex-wrap">
<span><span class="ownership-badge"></span> Owned</span>
<span><span class="ownership-badge"></span> Not owned</span>
</div>
{% if stage_label and stage_label.startswith('Creatures') %}
@ -348,7 +348,7 @@
{% endif %}
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
<div class="group-header">
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
<h5 class="my-2">{{ heading }}</h5>
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
</div>
@ -360,13 +360,13 @@
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="160px" />
</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
{% from 'partials/_macros.html' import lock_button %}
{{ lock_button(c.name, is_locked) }}
</div>
@ -377,7 +377,7 @@
</div>
{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<div class="card-actions-center">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
@ -385,13 +385,13 @@
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div class="flex justify-center mt-1">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts mt-1"></div>
</div>
{% endfor %}
</div>
@ -406,13 +406,13 @@
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="160px" />
</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
{% from 'partials/_macros.html' import lock_button %}
{{ lock_button(c.name, is_locked) }}
</div>
@ -423,7 +423,7 @@
</div>
{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<div class="card-actions-center">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
@ -431,31 +431,31 @@
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div class="flex justify-center mt-1">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
<div id="alts-{{ loop.index0 }}" class="alts mt-1"></div>
</div>
{% endfor %}
</div>
{% endif %}
{% if allow_must_haves and show_must_have_buttons %}
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. “Must include” will try to pull the card back in on future reruns, while “Must exclude” blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. "Must include" will try to pull the card back in on future reruns, while "Must exclude" blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
{% else %}
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
{% endif %}
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
<div data-empty hidden role="status" aria-live="polite" class="muted mt-2">
No cards match your filters.
</div>
{% endif %}
{% if show_logs and log %}
<details style="margin-top:1rem;">
<details class="mt-4">
<summary>Show logs</summary>
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
<pre class="build-log">{{ log }}</pre>
</details>
{% endif %}
@ -469,7 +469,7 @@
hx-get="/build/step5/summary?token={{ summary_token }}"
hx-trigger="load once, step5:refresh from:body"
hx-swap="outerHTML">
<div class="muted" style="margin-top:1rem;">
<div class="muted mt-4">
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
</div>
</div>

View file

@ -1,4 +1,6 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% block content %}
<section class="commander-page">
<header class="commander-hero">
@ -50,7 +52,7 @@
</select>
</label>
<input type="hidden" name="page" value="{{ page }}" />
<button type="submit" class="btn filter-submit">Apply</button>
{{ button('Apply', variant='primary', type='submit', classes='filter-submit') }}
</form>
<div

View file

@ -12,7 +12,7 @@
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander_base }}" />
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" width="320" data-card-name="{{ commander_base }}" />
</a>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">

View file

@ -35,7 +35,7 @@
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal"
<img src="{{ commander_base|card_image('normal') }}"
alt="{{ commander }} card image"
data-card-name="{{ commander_base }}"
data-original-name="{{ commander }}"

View file

@ -16,7 +16,7 @@
<div style="display:flex; align-items:center; gap:.5rem;">
<div style="width:64px; height:40px; background:#111; border:1px solid var(--border); border-radius:6px;">
<img class="card-thumb" alt="Thumb {{ i }}" loading="lazy" decoding="async" data-lqip
src="https://api.scryfall.com/cards/named?fuzzy=Lightning%20Bolt&format=image&version=small"
src="{{ 'Lightning Bolt'|card_image('small') }}"
width="64" height="40" style="width:64px; height:40px; object-fit:cover; border-radius:6px;" />
</div>
<div style="display:flex; flex-direction:column; gap:.25rem;">

View file

@ -0,0 +1,386 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button, icon_button, close_button, button_group, tag_button %}
{% from 'partials/_modals.html' import simple_modal, confirm_dialog, alert_modal %}
{% from 'partials/_forms.html' import text_input, textarea, select, checkbox, radio_group, number_input, file_input %}
{% from 'partials/_card_display.html' import card_thumb, card_flip_button, card_grid %}
{% from 'partials/_panels.html' import panel, simple_panel, info_panel, stat_panel, collapsible_panel, empty_state_panel, loading_panel %}
{% block title %}Component Library - MTG Deckbuilder{% endblock %}
{% block content %}
<div class="main-inner max-w-content" style="padding: 2rem 1rem;">
<div class="banner">
<h1>Component Library</h1>
<div class="subtitle">M2 standardized UI components for MTG Deckbuilder</div>
</div>
<!-- Table of Contents -->
{{ simple_panel(
title='Table of Contents',
content='
<ul style="list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
<li><a href="#buttons">Buttons</a></li>
<li><a href="#modals">Modals</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#cards">Card Display</a></li>
<li><a href="#panels">Panels</a></li>
</ul>
',
variant='alt'
) }}
<!-- BUTTONS -->
<section id="buttons" class="section-spacing">
{% call panel(title='Buttons', padding='lg') %}
{% block body %}
<h4 style="margin-top: 0;">Button Variants</h4>
<div class="btn-group content-spacing-lg">
{{ button('Primary Button', variant='primary') }}
{{ button('Secondary Button', variant='secondary') }}
{{ button('Ghost Button', variant='ghost') }}
{{ button('Danger Button', variant='danger') }}
</div>
<h4>Button Sizes</h4>
<div class="btn-group" style="margin-bottom: 2rem;">
{{ button('Small', size='sm') }}
{{ button('Medium (Default)', size='md') }}
{{ button('Large', size='lg') }}
</div>
<h4>Icon Buttons</h4>
<div class="btn-group" style="margin-bottom: 2rem;">
{{ icon_button('×', aria_label='Close', size='sm') }}
{{ icon_button('☰', aria_label='Menu', size='md') }}
{{ icon_button('⚙', aria_label='Settings', size='lg') }}
</div>
<h4>Tag Buttons</h4>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 2rem;">
{{ tag_button('Ramp') }}
{{ tag_button('Removal', selected=True) }}
{{ tag_button('Card Draw', removable=True, on_remove='alert("Tag removed")') }}
{{ tag_button('Counterspells') }}
</div>
<h4>Button Groups</h4>
{{ button_group([
{'text': 'Back', 'variant': 'secondary'},
{'text': 'Cancel', 'variant': 'ghost'},
{'text': 'Save', 'variant': 'primary', 'type': 'submit'}
], alignment='right') }}
<h4 style="margin-top: 2rem;">Link Buttons</h4>
<div class="btn-group">
{{ button('Go Home', href='/', variant='ghost') }}
{{ button('Build Deck', href='/build', variant='primary') }}
</div>
{% endblock %}
{% endcall %}
</section>
<!-- MODALS -->
<section id="modals" style="margin-top: 2rem;">
{% call panel(title='Modals', padding='lg') %}
{% block body %}
<p style="margin-top: 0; color: var(--muted);">Click buttons to see modal examples</p>
<div class="btn-group" style="margin-bottom: 1rem;">
{{ button('Simple Modal', onclick='showSimpleModalExample()') }}
{{ button('Confirm Dialog', onclick='showConfirmExample()') }}
{{ button('Alert (Success)', onclick='showAlertExample("success")') }}
{{ button('Alert (Error)', onclick='showAlertExample("error")') }}
</div>
<h4>Modal Sizes</h4>
<div class="btn-group">
{{ button('Small (480px)', onclick='showSizedModal("sm")') }}
{{ button('Medium (620px)', onclick='showSizedModal("md")') }}
{{ button('Large (720px)', onclick='showSizedModal("lg")') }}
{{ button('XLarge (960px)', onclick='showSizedModal("xl")') }}
</div>
{% endblock %}
{% endcall %}
</section>
<!-- FORMS -->
<section id="forms" style="margin-top: 2rem;">
{% call panel(title='Form Components', padding='lg') %}
{% block body %}
<form onsubmit="event.preventDefault(); alert('Form submitted!');" style="max-width: 600px;">
{{ text_input('username', label='Username', placeholder='Enter username', required=True) }}
{{ text_input('email', label='Email', type='email', placeholder='you@example.com', help_text='We\'ll never share your email') }}
{{ textarea('notes', label='Notes', placeholder='Enter additional notes...', rows=4) }}
{{ select('color', label='Color Identity', options=[
{'value': 'W', 'text': 'White'},
{'value': 'U', 'text': 'Blue'},
{'value': 'B', 'text': 'Black'},
{'value': 'R', 'text': 'Red'},
{'value': 'G', 'text': 'Green'}
], required=True) }}
{{ number_input('quantity', label='Quantity', min=1, max=10, value=1) }}
{{ checkbox('owned_only', label='Show only owned cards', checked=True) }}
{{ radio_group('theme', label='Preferred Theme', options=[
{'value': 'system', 'text': 'System', 'checked': True},
{'value': 'light', 'text': 'Light'},
{'value': 'dark', 'text': 'Dark'}
]) }}
{{ file_input('deck_file', label='Upload Deck File', accept='.csv,.txt,.json') }}
<div class="btn-group" style="margin-top: 2rem;">
{{ button('Cancel', variant='secondary', type='button') }}
{{ button('Submit', variant='primary', type='submit') }}
</div>
</form>
{% endblock %}
{% endcall %}
</section>
<!-- CARD DISPLAY -->
<section id="cards" style="margin-top: 2rem;">
{% call panel(title='Card Display Components', padding='lg') %}
{% block body %}
<h4 style="margin-top: 0;">Card Thumbnail Sizes</h4>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; margin-bottom: 2rem;">
{{ card_thumb('Sol Ring', size='small', show_name=True) }}
{{ card_thumb('Lightning Bolt', size='medium', show_name=True) }}
{{ card_thumb('Rampant Growth', size='large', show_name=True) }}
</div>
<h4>Dual-Faced Card with Flip Button</h4>
<div style="margin-bottom: 2rem;">
{{ card_thumb('Delver of Secrets // Insectile Aberration', size='large', layout='transform', show_flip=True, show_name=True) }}
</div>
<h4>Card Grid</h4>
<p style="color: var(--muted); font-size: 0.875rem;">Hover over cards to see popup (desktop) or tap (mobile)</p>
{{ card_grid([
{'name': 'Sol Ring', 'role': 'ramp', 'tags': ['Ramp', 'Artifact']},
{'name': 'Lightning Bolt', 'role': 'removal', 'tags': ['Removal', 'Instant']},
{'name': 'Rampant Growth', 'role': 'ramp', 'tags': ['Ramp', 'Sorcery']},
{'name': 'Counterspell', 'role': 'control', 'tags': ['Counterspell', 'Instant']},
{'name': 'Swords to Plowshares', 'role': 'removal', 'tags': ['Removal', 'Instant']},
{'name': 'Birds of Paradise', 'role': 'ramp', 'tags': ['Ramp', 'Creature']}
], size='medium', columns=3) }}
{% endblock %}
{% endcall %}
</section>
<!-- PANELS -->
<section id="panels" style="margin-top: 2rem;">
{% call panel(title='Panel Components', padding='lg') %}
{% block body %}
<h4 style="margin-top: 0;">Panel Variants</h4>
{{ simple_panel(title='Default Panel', content='<p>This is a default panel with standard background.</p>', variant='default') }}
{{ simple_panel(title='Alt Panel', content='<p>This is an alternate panel with lighter background.</p>', variant='alt') }}
{{ simple_panel(title='Dark Panel', content='<p>This is a dark panel with darker background.</p>', variant='dark') }}
{{ simple_panel(title='Bordered Panel', content='<p>This is a bordered panel with no background.</p>', variant='bordered') }}
<h4 style="margin-top: 2rem;">Info Panels</h4>
{{ info_panel(
icon='',
title='Information',
content='This is an informational message.',
type='info'
) }}
{{ info_panel(
icon='✅',
title='Success',
content='Operation completed successfully!',
type='success'
) }}
{{ info_panel(
icon='⚠️',
title='Warning',
content='Please review your selections.',
type='warning'
) }}
{{ info_panel(
icon='❌',
title='Error',
content='An error occurred. Please try again.',
type='error',
action_text='Retry',
action_onclick='alert("Retrying...")'
) }}
<h4 style="margin-top: 2rem;">Stat Panels</h4>
<div class="panel-grid panel-grid-cols-4">
{{ stat_panel('Total Cards', value=100) }}
{{ stat_panel('Avg MV', value='3.2', sublabel='Mana Value', variant='primary') }}
{{ stat_panel('Lands', value=37, variant='success') }}
{{ stat_panel('Budget', value='$125', variant='warning') }}
</div>
<h4 style="margin-top: 2rem;">Collapsible Panel</h4>
{% call collapsible_panel(title='Advanced Options', expanded=False) %}
{% block body %}
<p>These are advanced settings that are hidden by default.</p>
<ul>
<li>Option 1: Enable feature X</li>
<li>Option 2: Adjust threshold Y</li>
<li>Option 3: Configure behavior Z</li>
</ul>
{% endblock %}
{% endcall %}
<h4 style="margin-top: 2rem;">Empty State</h4>
{{ empty_state_panel(
icon='📋',
title='No Decks Found',
message='You haven\'t created any decks yet. Start building your first deck!',
action_text='Build Deck',
action_href='/build'
) }}
<h4 style="margin-top: 2rem;">Loading State</h4>
{{ loading_panel(message='Building deck...', spinner=True) }}
{% endblock %}
{% endcall %}
</section>
<!-- Back to Top -->
<div style="margin-top: 3rem; text-align: center;">
{{ button('Back to Top', href='#', variant='ghost', onclick='window.scrollTo({top:0,behavior:"smooth"}); return false;') }}
</div>
</div>
<script>
// Modal examples
function showSimpleModalExample() {
const modal = document.createElement('div');
modal.innerHTML = `{{ simple_modal(
title='Example Modal',
content='<p>This is a simple modal with content. You can put any HTML here.</p><p>Click outside or press Escape to close.</p>',
footer_buttons=[
{'text': 'Cancel', 'variant': 'secondary', 'onclick': "this.closest('.modal').remove()"},
{'text': 'OK', 'variant': 'primary', 'onclick': "alert('OK clicked'); this.closest('.modal').remove()"}
],
size='md'
) }}`;
document.body.appendChild(modal.firstElementChild);
}
function showConfirmExample() {
const modal = document.createElement('div');
modal.innerHTML = `{{ confirm_dialog(
message='Are you sure you want to delete this deck?',
confirm_text='Delete',
confirm_variant='danger',
on_confirm="alert('Deleted!'); this.closest('.modal').remove()"
) }}`;
document.body.appendChild(modal.firstElementChild);
}
function showAlertExample(type) {
const messages = {
success: 'Deck saved successfully!',
error: 'Failed to save deck. Please try again.'
};
const titles = {
success: 'Success',
error: 'Error'
};
const modal = document.createElement('div');
modal.className = 'modal modal-sm modal-center modal-alert modal-alert-' + type;
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = `
<div class="modal-backdrop" onclick="this.closest('.modal').remove()"></div>
<div class="modal-content">
<div class="modal-body">
<div class="alert-icon alert-icon-${type}"></div>
<p>${messages[type]}</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="this.closest('.modal').remove()">OK</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
function showSizedModal(size) {
const sizes = {
sm: '480px',
md: '620px',
lg: '720px',
xl: '960px'
};
const modal = document.createElement('div');
modal.className = `modal modal-${size} modal-center`;
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = `
<div class="modal-backdrop" onclick="this.closest('.modal').remove()"></div>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">${size.toUpperCase()} Modal (${sizes[size]} max-width)</h2>
<button type="button" class="btn btn-icon btn-ghost btn-sm btn-close" onclick="this.closest('.modal').remove()" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p>This is a ${size.toUpperCase()} sized modal with a maximum width of ${sizes[size]}.</p>
<p>Modal content goes here. You can put forms, cards, or any other content.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// Setup card popups after page load
document.addEventListener('DOMContentLoaded', function() {
setupCardPopups();
});
</script>
<style>
/* Component library specific styles */
h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
section {
scroll-margin-top: 2rem;
}
a {
color: var(--ring);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
{% endblock %}

View file

@ -1,13 +1,25 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% from 'partials/_panels.html' import simple_panel %}
{% block content %}
<section>
<h2>Page not found</h2>
<p>The page you requested could not be found.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
<details>
{{ simple_panel(
title='Page Not Found',
content='<p>The page you requested could not be found.</p>
<p class="muted">Request ID: <code>' ~ (request_id or request.state.request_id) ~ '</code></p>',
variant='bordered',
padding='lg'
) }}
<div style="margin-top: 1rem;">
{{ button('Go Home', variant='primary', href='/') }}
</div>
<details style="margin-top: 1rem;">
<summary>Details</summary>
<pre>Status: {{ status }}
Path: {{ request.url.path }}</pre>
</details>
</section>
{% endblock %}

View file

@ -1,8 +1,17 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% from 'partials/_panels.html' import info_panel %}
{% block content %}
<section>
<h2>Internal Server Error</h2>
<p>Something went wrong.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
{{ info_panel(
icon='❌',
title='Internal Server Error',
content='Something went wrong. Our team has been notified.<br><br>
<span class="muted">Request ID: <code>' ~ (request_id or request.state.request_id) ~ '</code></span>',
type='error',
action_text='Go Home',
action_href='/'
) }}
</section>
{% endblock %}

View file

@ -1,18 +1,20 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% block content %}
<section>
<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 %}
<a class="action-button" href="/owned">Owned Library</a>
<a class="action-button" href="/cards">Browse All Cards</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_diagnostics %}<a class="action-button" href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
{{ button('Build a Deck', variant='primary', href='/build', classes='action-button home-button') }}
{{ button('Run a JSON Config', variant='secondary', href='/configs', classes='action-button home-button') }}
{% if show_setup %}{{ button('Initial Setup', variant='secondary', href='/setup', classes='action-button home-button') }}{% endif %}
{{ button('Owned Library', variant='secondary', href='/owned', classes='action-button home-button') }}
{{ button('Browse All Cards', variant='secondary', href='/cards', classes='action-button home-button') }}
{% if show_commanders %}{{ button('Browse Commanders', variant='secondary', href='/commanders', classes='action-button home-button') }}{% endif %}
{{ button('Finished Decks', variant='secondary', href='/decks', classes='action-button home-button') }}
{{ button('Browse Themes', variant='secondary', href='/themes/', classes='action-button home-button') }}
{% if random_ui %}{{ button('Random Build', variant='secondary', href='/random', classes='action-button home-button') }}{% endif %}
{% if show_diagnostics %}{{ button('Diagnostics', variant='secondary', href='/diagnostics', classes='action-button home-button') }}{% endif %}
{% if show_logs %}{{ button('View Logs', variant='secondary', href='/logs', classes='action-button home-button') }}{% 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

@ -81,8 +81,8 @@
<label class="owned-row" style="cursor:pointer;" tabindex="0" data-card-name="{{ n }}" data-original-name="{{ n }}">
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
<div class="owned-vstack">
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="{{ n|card_image('small') }}" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
srcset="{{ n|card_image('small') }} 160w, {{ n|card_image('normal') }} 488w"
sizes="160px" />
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% if cols and cols|length %}
@ -137,7 +137,7 @@
// Helper: build Scryfall image URL with optional cache-busting
function buildImageUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
var url = '/api/images/' + (version||'small') + '/' + q;
if (nocache) url += '&t=' + Date.now();
return url;
}

View file

@ -0,0 +1,225 @@
{# Button Component Library #}
{# Usage: {{ import_buttons() }} then call button macros #}
{#
Primary Button Macro
Parameters:
- text (str): Button text/label
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'primary')
- type (str): 'button', 'submit', 'reset' (default: 'button')
- size (str): 'sm', 'md', 'lg' (default: 'md')
- href (str): If provided, renders as <a> tag instead of <button>
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes (e.g., 'disabled', 'data-foo="bar"')
- hx_get (str): HTMX hx-get attribute
- hx_post (str): HTMX hx-post attribute
- hx_target (str): HTMX hx-target attribute
- hx_swap (str): HTMX hx-swap attribute
- onclick (str): JavaScript onclick handler
- aria_label (str): ARIA label for accessibility
Examples:
{{ button('Click Me') }}
{{ button('Submit', variant='primary', type='submit') }}
{{ button('Cancel', variant='secondary') }}
{{ button('Delete', variant='danger', onclick='confirmDelete()') }}
{{ button('Go Back', variant='ghost', href='/build') }}
{{ button('Load More', hx_get='/cards?page=2', hx_target='#cards') }}
#}
{% macro button(text, variant='primary', type='button', size='md', href='', classes='', attrs='', hx_get='', hx_post='', hx_target='', hx_swap='', onclick='', aria_label='') %}
{%- set base_classes = 'btn' -%}
{%- set variant_class = 'btn-' + variant if variant != 'primary' else '' -%}
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
{%- if href -%}
<a href="{{ href }}"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{{ attrs|safe }}>{{ text }}</a>
{%- else -%}
<button type="{{ type }}"
class="{{ all_classes }}"
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{{ attrs|safe }}>{{ text }}</button>
{%- endif -%}
{% endmacro %}
{#
Icon Button Macro
Parameters:
- icon (str): Icon character or HTML (e.g., '×', '☰', '<svg>...</svg>')
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'ghost')
- size (str): 'sm', 'md', 'lg' (default: 'md')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- onclick (str): JavaScript onclick handler
- aria_label (str): Required ARIA label for accessibility
Examples:
{{ icon_button('×', aria_label='Close', onclick='closeModal()') }}
{{ icon_button('☰', aria_label='Menu', variant='primary') }}
#}
{% macro icon_button(icon, variant='ghost', size='md', classes='', attrs='', onclick='', aria_label='') %}
{%- set base_classes = 'btn btn-icon' -%}
{%- set variant_class = 'btn-' + variant if variant != 'ghost' else '' -%}
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
<button type="button"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% else %}aria-label="Icon button"{% endif %}
{{ attrs|safe }}>{{ icon|safe }}</button>
{% endmacro %}
{#
Close Button Macro (specialized icon button)
Parameters:
- target (str): CSS selector of element to close (default: closest '.modal')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- aria_label (str): ARIA label (default: 'Close')
Examples:
{{ close_button() }}
{{ close_button(target='.alts') }}
{{ close_button(classes='modal-close') }}
#}
{% macro close_button(target='.modal', classes='', attrs='', aria_label='Close') %}
{{ icon_button(
'×',
variant='ghost',
size='sm',
classes='btn-close ' + classes,
onclick="try{this.closest('" + target + "').remove();}catch(_){}",
aria_label=aria_label,
attrs=attrs
) }}
{% endmacro %}
{#
Button Group Macro
Parameters:
- buttons (list): List of button dicts with keys: text, variant, type, href, onclick, etc.
- alignment (str): 'left', 'center', 'right', 'between' (default: 'right')
- classes (str): Additional CSS classes for container
Examples:
{{ button_group([
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'close()'},
{'text': 'Save', 'variant': 'primary', 'type': 'submit'}
]) }}
#}
{% macro button_group(buttons, alignment='right', classes='') %}
{%- set alignment_class = 'btn-group-' + alignment -%}
<div class="btn-group {{ alignment_class }} {{ classes }}">
{% for btn in buttons %}
{{ button(
btn.text,
variant=btn.get('variant', 'primary'),
type=btn.get('type', 'button'),
size=btn.get('size', 'md'),
href=btn.get('href', ''),
classes=btn.get('classes', ''),
attrs=btn.get('attrs', ''),
hx_get=btn.get('hx_get', ''),
hx_post=btn.get('hx_post', ''),
hx_target=btn.get('hx_target', ''),
hx_swap=btn.get('hx_swap', ''),
onclick=btn.get('onclick', ''),
aria_label=btn.get('aria_label', '')
) }}
{% endfor %}
</div>
{% endmacro %}
{#
Tag/Chip Button Macro
Parameters:
- text (str): Tag text
- removable (bool): Show remove 'x' button (default: False)
- selected (bool): Tag is selected (default: False)
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- onclick (str): JavaScript onclick handler
- on_remove (str): JavaScript handler for remove button
- data_attrs (dict): Data attributes as key-value pairs
Examples:
{{ tag_button('Flying') }}
{{ tag_button('Ramp', selected=True) }}
{{ tag_button('Blue', removable=True, on_remove='removeTag(this)') }}
{{ tag_button('Simic', data_attrs={'color': 'ug', 'value': '2'}) }}
#}
{% macro tag_button(text, removable=False, selected=False, classes='', attrs='', onclick='', on_remove='', data_attrs={}) %}
{%- set base_classes = 'btn btn-tag' -%}
{%- set state_class = 'btn-tag-selected' if selected else '' -%}
{%- set all_classes = [base_classes, state_class, classes]|select|join(' ') -%}
<button type="button"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% for key, value in data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
{{ attrs|safe }}>
<span>{{ text }}</span>
{% if removable %}
<button type="button"
class="btn-tag-remove"
aria-label="Remove {{ text }}"
{% if on_remove %}onclick="{{ on_remove }}"{% else %}onclick="this.closest('.btn-tag').remove()"{% endif %}>×</button>
{% endif %}
</button>
{% endmacro %}
{#
Action Button (Legacy - use button() with variant instead)
Kept for backward compatibility during migration
Parameters: Same as button()
Note: This is deprecated. Use {{ button(text, variant='primary', size='lg') }} instead.
#}
{% macro action_button(text, type='button', classes='', attrs='', onclick='', aria_label='') %}
{{ button(text, variant='primary', type=type, size='lg', classes='action-btn ' + classes, attrs=attrs, onclick=onclick, aria_label=aria_label) }}
{% endmacro %}
{# CSS Classes Reference #}
{#
Button Variants:
- .btn (base)
- .btn-primary (default)
- .btn-secondary (gray, for cancel/back)
- .btn-ghost (transparent, subtle)
- .btn-danger (red, for destructive actions)
Button Sizes:
- .btn-sm (small: padding 4px 12px, font 12px)
- .btn-md (default: padding 8px 16px, font 14px)
- .btn-lg (large: padding 12px 24px, font 16px)
Button Modifiers:
- .btn-icon (icon-only button, square aspect)
- .btn-close (close button, positioned top-right)
- .btn-tag (pill-shaped tag/chip)
- .btn-tag-selected (selected tag state)
- .btn-tag-remove (remove button within tag)
Button Groups:
- .btn-group (container)
- .btn-group-left (align left)
- .btn-group-center (align center)
- .btn-group-right (align right, default)
- .btn-group-between (space-between)
#}

View file

@ -0,0 +1,375 @@
{# Card Display Component Library #}
{# Usage: {{ import '_card_display.html' }} then call card macros #}
{#
Card Thumbnail Macro
Parameters:
- name (str): Card name (required)
- size (str): 'small' (160px), 'medium' (230px), 'large' (360px) (default: 'medium')
- layout (str): Card layout type ('modal_dfc', 'transform', 'normal', etc.)
- version (str): Scryfall image version ('small', 'normal', 'large') (auto-selected by size)
- loading (str): 'lazy', 'eager' (default: 'lazy')
- show_flip (bool): Show flip button for dual-faced cards (default: True)
- show_name (bool): Show card name label below image (default: False)
- classes (str): Additional CSS classes for container
- img_classes (str): Additional CSS classes for img tag
- data_attrs (dict): Additional data attributes as key-value pairs
- role (str): Card role (commander, ramp, removal, etc.)
- tags (list or str): Theme/mechanic tags (list or comma-separated string)
- overlaps (list or str): Theme overlaps
- count (int): Card count in deck
- lqip (bool): Use low-quality image placeholder (default: True)
- onclick (str): JavaScript onclick handler
Examples:
{{ card_thumb('Sol Ring', size='medium') }}
{{ card_thumb('Halana, Kessig Ranger', size='large', show_name=True) }}
{{ card_thumb('Delver of Secrets', layout='transform', show_flip=True) }}
{{ card_thumb('Rampant Growth', role='ramp', tags=['Ramp', 'Green']) }}
#}
{% macro card_thumb(name, size='medium', layout='normal', version='', loading='lazy', show_flip=True, show_name=False, classes='', img_classes='', data_attrs={}, role='', tags='', overlaps='', count=0, lqip=True, onclick='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
{# Auto-select Scryfall image version based on size #}
{%- if not version -%}
{%- if size == 'small' -%}
{%- set version = 'small' -%}
{%- elif size == 'large' -%}
{%- set version = 'normal' -%}
{%- else -%}
{%- set version = 'small' -%}
{%- endif -%}
{%- endif -%}
{# Build CSS classes #}
{%- set size_class = 'card-thumb-' + size -%}
{%- set dfc_class = 'card-thumb-dfc' if is_dfc else '' -%}
{%- set container_classes = ['card-thumb-container', size_class, dfc_class, classes]|select|join(' ') -%}
{%- set img_base_classes = 'card-thumb' -%}
{%- set all_img_classes = [img_base_classes, img_classes]|select|join(' ') -%}
{# Build data attributes #}
{%- set all_data_attrs = {
'card-name': base_name,
'layout': layout
} -%}
{%- if role -%}
{%- set _ = all_data_attrs.update({'role': role}) -%}
{%- endif -%}
{%- if tags -%}
{%- set tags_str = tags if tags is string else tags|join(', ') -%}
{%- set _ = all_data_attrs.update({'tags': tags_str}) -%}
{%- endif -%}
{%- if overlaps -%}
{%- set overlaps_str = overlaps if overlaps is string else overlaps|join(',') -%}
{%- set _ = all_data_attrs.update({'overlaps': overlaps_str}) -%}
{%- endif -%}
{%- if count > 0 -%}
{%- set _ = all_data_attrs.update({'count': count|string}) -%}
{%- endif -%}
{%- if lqip -%}
{%- set _ = all_data_attrs.update({'lqip': '1'}) -%}
{%- endif -%}
{%- set _ = all_data_attrs.update(data_attrs) -%}
<div class="{{ container_classes }}" {% if onclick %}onclick="{{ onclick }}"{% endif %}>
<img class="{{ all_img_classes }}"
loading="{{ loading }}"
decoding="async"
src="{{ base_name|card_image(version) }}"
alt="{{ name }} image"
{% for key, value in all_data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
{% if lqip %}style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';"{% endif %} />
{% if is_dfc and show_flip %}
{{ card_flip_button(name) }}
{% endif %}
{% if show_name %}
<div class="card-name-label" data-card-name="{{ base_name }}">{{ name }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Card Flip Button Macro
Parameters:
- name (str): Full card name (with // separator for DFCs)
- classes (str): Additional CSS classes
- aria_label (str): ARIA label (default: auto-generated)
Examples:
{{ card_flip_button('Delver of Secrets // Insectile Aberration') }}
#}
{% macro card_flip_button(name, classes='', aria_label='') %}
{%- set faces = name.split(' // ') -%}
{%- set label = aria_label if aria_label else 'Flip to ' + (faces[1] if faces|length > 1 else 'other face') -%}
<button type="button"
class="card-flip-btn {{ classes }}"
data-card-name="{{ name }}"
onclick="flipCard(this)"
aria-label="{{ label }}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
</svg>
</button>
{% endmacro %}
{#
Card Hover Popup Macro
Parameters:
- name (str): Card name (required)
- layout (str): Card layout type
- tags (list or str): Theme/mechanic tags
- highlight_tags (list or str): Tags to highlight
- role (str): Card role
- show_flip (bool): Show flip button for DFCs (default: True)
- classes (str): Additional CSS classes
Note: This macro generates the popup HTML. Actual hover/tap behavior
should be handled by JavaScript (see card_popup.js)
Examples:
{{ card_popup('Sol Ring', tags=['Ramp', 'Artifact']) }}
{{ card_popup('Delver of Secrets', layout='transform', show_flip=True) }}
#}
{% macro card_popup(name, layout='normal', tags='', highlight_tags='', role='', show_flip=True, classes='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
{%- set tags_list = tags if tags is sequence and tags is not string else (tags.split(', ') if tags else []) -%}
{%- set highlight_list = highlight_tags if highlight_tags is sequence and highlight_tags is not string else (highlight_tags.split(', ') if highlight_tags else []) -%}
<div class="card-popup {{ classes }}" data-card-name="{{ base_name }}" role="dialog" aria-label="{{ name }} details">
<div class="card-popup-backdrop" onclick="closeCardPopup(this)"></div>
<div class="card-popup-content">
{# Card Image (360px) #}
<div class="card-popup-image">
<img src="{{ base_name|card_image('normal') }}"
alt="{{ name }} image"
data-card-name="{{ base_name }}"
loading="lazy"
decoding="async" />
{% if is_dfc and show_flip %}
{{ card_flip_button(name) }}
{% endif %}
</div>
{# Card Info #}
<div class="card-popup-info">
<h3 class="card-popup-name">{{ name }}</h3>
{% if role %}
<div class="card-popup-role">Role: <span>{{ role }}</span></div>
{% endif %}
{% if tags_list %}
<div class="card-popup-tags">
{% for tag in tags_list %}
{%- set is_highlight = tag in highlight_list -%}
<span class="card-popup-tag{% if is_highlight %} card-popup-tag-highlight{% endif %}">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{# Close Button #}
<button type="button"
class="card-popup-close"
onclick="closeCardPopup(this)"
aria-label="Close">×</button>
</div>
</div>
{% endmacro %}
{#
Card Grid Container Macro
Parameters:
- cards (list): List of card dicts with keys: name, layout, role, tags, count, etc.
- size (str): Thumbnail size ('small', 'medium', 'large')
- columns (int or str): Number of columns (auto, 2, 3, 4, 5, 6) (default: 'auto')
- gap (str): Grid gap (default: '0.75rem')
- show_names (bool): Show card name labels (default: False)
- show_popups (bool): Enable hover/tap popups (default: True)
- classes (str): Additional CSS classes
Examples:
{{ card_grid(deck_cards, size='medium', columns=4) }}
{{ card_grid(commander_examples, size='large', show_names=True) }}
#}
{% macro card_grid(cards, size='medium', columns='auto', gap='0.75rem', show_names=False, show_popups=True, classes='') %}
{%- set columns_class = 'card-grid-cols-' + (columns|string) -%}
{%- set popup_class = 'card-grid-with-popups' if show_popups else '' -%}
{%- set all_classes = ['card-grid', columns_class, popup_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" style="gap: {{ gap }};">
{% for card in cards %}
{{ card_thumb(
name=card.name,
size=size,
layout=card.get('layout', 'normal'),
role=card.get('role', ''),
tags=card.get('tags', []),
overlaps=card.get('overlaps', []),
count=card.get('count', 0),
show_name=show_names,
show_flip=True
) }}
{% endfor %}
</div>
{% endmacro %}
{#
Card List Item Macro (for vertical lists)
Parameters:
- name (str): Card name (required)
- count (int): Card quantity (default: 1)
- role (str): Card role
- tags (list or str): Theme/mechanic tags
- show_thumb (bool): Show thumbnail image (default: True)
- thumb_size (str): Thumbnail size if shown (default: 'small')
- classes (str): Additional CSS classes
Examples:
{{ card_list_item('Sol Ring', count=1, role='ramp') }}
{{ card_list_item('Rampant Growth', count=1, tags=['Ramp', 'Green'], show_thumb=True) }}
#}
{% macro card_list_item(name, count=1, role='', tags='', show_thumb=True, thumb_size='small', classes='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
<li class="card-list-item {{ classes }}" data-card-name="{{ base_name }}">
{% if show_thumb %}
{{ card_thumb(name, size=thumb_size, show_flip=False, role=role, tags=tags) }}
{% endif %}
<div class="card-list-item-info">
<span class="card-list-item-name">{{ name }}</span>
{% if count > 1 %}
<span class="card-list-item-count">×{{ count }}</span>
{% endif %}
{% if role %}
<span class="card-list-item-role">{{ role }}</span>
{% endif %}
</div>
</li>
{% endmacro %}
{#
Synthetic Card Placeholder Macro (for theme previews)
Parameters:
- name (str): Card name (required)
- tags (list or str): Theme/mechanic tags
- reasons (list or str): Inclusion reasons
- classes (str): Additional CSS classes
Examples:
{{ synthetic_card('Placeholder Ramp', tags=['Ramp'], reasons=['synergy with commander']) }}
#}
{% macro synthetic_card(name, tags='', reasons='', classes='') %}
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
{%- set reasons_str = reasons if reasons is string else (reasons|join('; ') if reasons else '') -%}
<div class="card-sample synthetic {{ classes }}"
data-card-name="{{ name }}"
data-role="synthetic"
data-tags="{{ tags_str }}"
data-reasons="{{ reasons_str }}">
<div class="synthetic-card-placeholder">
<div class="synthetic-card-icon">?</div>
<div class="synthetic-card-name">{{ name }}</div>
{% if reasons_str %}
<div class="synthetic-card-reason">{{ reasons_str }}</div>
{% endif %}
</div>
</div>
{% endmacro %}
{# CSS Classes Reference #}
{#
Card Thumbnail Sizes:
- .card-thumb-small (160px width, for lists and grids)
- .card-thumb-medium (230px width, for previews and examples, default)
- .card-thumb-large (360px width, for prominent displays and deck views)
Card Thumbnail Modifiers:
- .card-thumb-dfc (dual-faced card, shows flip button)
- .card-thumb-container (wrapper with position relative)
- .card-thumb (img tag with consistent styling)
Card Flip Button:
- .card-flip-btn (flip button overlay on card image)
Card Popup:
- .card-popup (popup container, fixed positioning)
- .card-popup-backdrop (backdrop overlay)
- .card-popup-content (popup content box)
- .card-popup-image (360px card image)
- .card-popup-info (card name, role, tags)
- .card-popup-name (card name heading)
- .card-popup-role (role label)
- .card-popup-tags (tag list)
- .card-popup-tag (individual tag)
- .card-popup-tag-highlight (highlighted tag)
- .card-popup-close (close button)
Card Grid:
- .card-grid (grid container)
- .card-grid-cols-auto (auto columns based on card size)
- .card-grid-cols-2, .card-grid-cols-3, etc. (fixed columns)
- .card-grid-with-popups (enables popup on hover/tap)
Card List:
- .card-list-item (list item with thumbnail and info)
- .card-list-item-info (text info container)
- .card-list-item-name (card name)
- .card-list-item-count (quantity indicator)
- .card-list-item-role (role label)
Synthetic Cards:
- .card-sample.synthetic (synthetic card placeholder)
- .synthetic-card-placeholder (placeholder content)
- .synthetic-card-icon (question mark icon)
- .synthetic-card-name (placeholder name)
- .synthetic-card-reason (inclusion reason text)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in card_display.js or inline script:
// Flip dual-faced card image
function flipCard(button) {
const container = button.closest('.card-thumb-container, .card-popup-image');
const img = container.querySelector('img');
const cardName = img.dataset.cardName;
const faces = cardName.split(' // ');
if (faces.length < 2) return;
// Toggle current face
const currentFace = img.dataset.currentFace || 0;
const nextFace = currentFace == 0 ? 1 : 0;
const faceName = faces[nextFace];
// Update image source
img.src = '/api/images/normal/' + encodeURIComponent(faceName);
img.dataset.currentFace = nextFace;
}
// Show card popup on hover/tap
function showCardPopup(cardName, event) {
// Implementation depends on popup positioning strategy
// Could append popup to body and position near cursor/tap location
}
// Close card popup
function closeCardPopup(element) {
const popup = element.closest('.card-popup');
if (popup) popup.remove();
}
#}

View file

@ -0,0 +1,396 @@
{# Form Component Library #}
{# Usage: {{ import '_forms.html' }} then call form macros #}
{#
Form Field Wrapper Macro
Parameters:
- label (str): Field label text
- name (str): Input name attribute
- required (bool): Mark field as required (default: False)
- help_text (str): Optional help text below field
- error (str): Error message to display
- classes (str): Additional CSS classes for wrapper
Content Block:
- body: Input element (required)
Examples:
{% call form_field('Email', 'email', required=True) %}
{% block body %}
<input type="email" name="email" />
{% endblock %}
{% endcall %}
#}
{% macro form_field(label='', name='', required=False, help_text='', error='', classes='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if label %}
<label for="{{ name }}" class="form-label">
{{ label }}
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
</label>
{% endif %}
<div class="form-input-wrapper">
{{ caller.body() }}
</div>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Text Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- type (str): Input type ('text', 'email', 'password', 'url', etc.) (default: 'text')
- value (str): Input value
- placeholder (str): Placeholder text
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- readonly (bool): Read-only field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ text_input('username', label='Username', required=True) }}
{{ text_input('email', label='Email', type='email', placeholder='you@example.com') }}
#}
{% macro text_input(name, label='', type='text', value='', placeholder='', required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
class="form-input"
{% if value %}value="{{ value }}"{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Textarea Macro
Parameters:
- name (str): Textarea name attribute (required)
- label (str): Field label
- value (str): Textarea value
- placeholder (str): Placeholder text
- rows (int): Number of visible rows (default: 4)
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- readonly (bool): Read-only field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ textarea('notes', label='Notes', rows=6) }}
{{ textarea('description', label='Description', placeholder='Enter description...') }}
#}
{% macro textarea(name, label='', value='', placeholder='', rows=4, required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<textarea id="{{ name }}"
name="{{ name }}"
class="form-textarea"
rows="{{ rows }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{{ attrs|safe }}>{{ value }}</textarea>
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Select Dropdown Macro
Parameters:
- name (str): Select name attribute (required)
- label (str): Field label
- options (list): List of option dicts with keys: value, text, selected, disabled
- value (str): Selected value
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ select('color', label='Color', options=[
{'value': 'W', 'text': 'White'},
{'value': 'U', 'text': 'Blue'},
{'value': 'B', 'text': 'Black'}
]) }}
#}
{% macro select(name, label='', options=[], value='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<select id="{{ name }}"
name="{{ name }}"
class="form-select"
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }}>
{% for option in options %}
<option value="{{ option.value }}"
{% if option.value == value or option.get('selected') %}selected{% endif %}
{% if option.get('disabled') %}disabled{% endif %}>
{{ option.text }}
</option>
{% endfor %}
</select>
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Checkbox Macro
Parameters:
- name (str): Checkbox name attribute (required)
- label (str): Checkbox label text (required)
- value (str): Checkbox value (default: '1')
- checked (bool): Checked state (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ checkbox('accept_terms', label='I accept the terms', required=True) }}
{{ checkbox('owned_only', label='Owned cards only', checked=True) }}
#}
{% macro checkbox(name, label, value='1', checked=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', 'form-field-checkbox', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
<label class="form-checkbox-label">
<input type="checkbox"
id="{{ name }}"
name="{{ name }}"
class="form-checkbox"
value="{{ value }}"
{% if checked %}checked{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
<span class="form-checkbox-text">{{ label }}</span>
</label>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Radio Button Group Macro
Parameters:
- name (str): Radio name attribute (required)
- label (str): Field label
- options (list): List of option dicts with keys: value, text, checked, disabled
- value (str): Selected value
- required (bool): Required field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
Examples:
{{ radio_group('theme', label='Theme', options=[
{'value': 'system', 'text': 'System'},
{'value': 'light', 'text': 'Light'},
{'value': 'dark', 'text': 'Dark'}
]) }}
#}
{% macro radio_group(name, label='', options=[], value='', required=False, help_text='', error='', classes='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', 'form-field-radio', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if label %}
<div class="form-label">
{{ label }}
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
</div>
{% endif %}
<div class="form-radio-group">
{% for option in options %}
<label class="form-radio-label">
<input type="radio"
name="{{ name }}"
class="form-radio"
value="{{ option.value }}"
{% if option.value == value or option.get('checked') %}checked{% endif %}
{% if option.get('disabled') %}disabled{% endif %}
{% if required %}required{% endif %} />
<span class="form-radio-text">{{ option.text }}</span>
</label>
{% endfor %}
</div>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Number Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- value (int or float): Input value
- min (int or float): Minimum value
- max (int or float): Maximum value
- step (int or float): Step increment (default: 1)
- placeholder (str): Placeholder text
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ number_input('quantity', label='Quantity', min=1, max=10, value=1) }}
{{ number_input('price', label='Price', min=0, step=0.01, placeholder='0.00') }}
#}
{% macro number_input(name, label='', value='', min='', max='', step=1, placeholder='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="number"
id="{{ name }}"
name="{{ name }}"
class="form-input form-input-number"
{% if value != '' %}value="{{ value }}"{% endif %}
{% if min != '' %}min="{{ min }}"{% endif %}
{% if max != '' %}max="{{ max }}"{% endif %}
step="{{ step }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
File Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- accept (str): Accepted file types (e.g., '.csv,.txt', 'image/*')
- multiple (bool): Allow multiple files (default: False)
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ file_input('deck_file', label='Upload Deck', accept='.csv,.txt') }}
{{ file_input('cards', label='Card Images', accept='image/*', multiple=True) }}
#}
{% macro file_input(name, label='', accept='', multiple=False, required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="file"
id="{{ name }}"
name="{{ name }}"
class="form-input form-input-file"
{% if accept %}accept="{{ accept }}"{% endif %}
{% if multiple %}multiple{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Hidden Input Macro
Parameters:
- name (str): Input name attribute (required)
- value (str): Input value (required)
Examples:
{{ hidden_input('csrf_token', value='abc123') }}
#}
{% macro hidden_input(name, value) %}
<input type="hidden" name="{{ name }}" value="{{ value }}" />
{% endmacro %}
{# CSS Classes Reference #}
{#
Form Structure:
- .form-field (field wrapper)
- .form-field-error (error state)
- .form-field-checkbox (checkbox field modifier)
- .form-field-radio (radio field modifier)
- .form-label (label text)
- .form-required (required indicator *)
- .form-input-wrapper (input container)
- .form-help-text (help text below field)
- .form-error-text (error message)
Input Types:
- .form-input (base input class)
- .form-input-number (number input modifier)
- .form-input-file (file input modifier)
- .form-textarea (textarea)
- .form-select (select dropdown)
- .form-checkbox (checkbox input)
- .form-checkbox-label (checkbox label wrapper)
- .form-checkbox-text (checkbox label text)
- .form-radio (radio input)
- .form-radio-group (radio button container)
- .form-radio-label (radio label wrapper)
- .form-radio-text (radio label text)
Form Layout Utilities (to be defined in CSS):
- .form-row (horizontal row of fields)
- .form-cols-2, .form-cols-3 (multi-column layouts)
- .form-inline (inline form layout)
- .form-compact (reduced spacing)
#}

View file

@ -1,5 +1,19 @@
{# Reusable Jinja macros for UI elements #}
{#
Component Library Imports
To use components in your templates:
{% from 'partials/_macros.html' import component_name %}
Or import specific component libraries:
{% from 'partials/_buttons.html' import button, icon_button %}
{% from 'partials/_modals.html' import modal, simple_modal %}
{% from 'partials/_card_display.html' import card_thumb, card_grid %}
{% from 'partials/_forms.html' import text_input, select, checkbox %}
{% from 'partials/_panels.html' import panel, stat_panel %}
#}
{% macro lock_button(name, locked=False, from_list=False, target_selector='closest .lock-box') -%}
{# Emits a lock/unlock button with correct hx-vals and aria state. #}
<button type="button" class="btn-lock"

View file

@ -0,0 +1,351 @@
{# Modal Component Library #}
{# Usage: {{ import '_modals.html' }} then call modal macros #}
{#
Modal Container Macro
Parameters:
- id (str): Modal HTML id attribute (optional)
- title (str): Modal title (shows in header)
- size (str): 'sm' (480px), 'md' (620px), 'lg' (720px), 'xl' (960px) (default: 'md')
- position (str): 'center', 'top' (default: 'center')
- scrollable (bool): Allow content scrolling (default: True)
- classes (str): Additional CSS classes for modal container
- content_classes (str): Additional CSS classes for modal content box
- show_close (bool): Show close button in header (default: True)
- backdrop_click_close (bool): Close on backdrop click (default: True)
- role (str): ARIA role (default: 'dialog')
- aria_labelledby (str): ARIA labelledby id (default: auto-generated from title)
Content Blocks:
- header: Optional custom header content (overrides default title)
- body: Main modal content (required)
- footer: Optional footer with action buttons
Examples:
{% call modal(title='Edit Card', size='md') %}
{% block body %}
<form>...</form>
{% endblock %}
{% block footer %}
{{ button('Cancel', variant='secondary', onclick='closeModal()') }}
{{ button('Save', type='submit') }}
{% endblock %}
{% endcall %}
#}
{% macro modal(id='', title='', size='md', position='center', scrollable=True, classes='', content_classes='', show_close=True, backdrop_click_close=True, role='dialog', aria_labelledby='') %}
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = aria_labelledby if aria_labelledby else modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set position_class = 'modal-' + position -%}
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
{% if id %}id="{{ modal_id }}"{% endif %}
role="{{ role }}"
aria-modal="true"
aria-labelledby="{{ title_id }}">
{# Backdrop #}
<div class="modal-backdrop"
{% if backdrop_click_close %}onclick="try{this.closest('.modal').remove();}catch(_){}"{% endif %}></div>
{# Content Container #}
<div class="modal-content {{ content_classes }}">
{# Header #}
{% if caller.header is defined %}
{{ caller.header() }}
{% else %}
{% if title or show_close %}
<div class="modal-header">
{% if title %}
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% endif %}
{% if show_close %}
{% from '_buttons.html' import close_button %}
{{ close_button() }}
{% endif %}
</div>
{% endif %}
{% endif %}
{# Body #}
<div class="modal-body">
{{ caller.body() }}
</div>
{# Footer #}
{% if caller.footer is defined %}
<div class="modal-footer">
{{ caller.footer() }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Simple Modal Macro (no block structure)
Parameters: Same as modal() plus:
- content (str): Body HTML content
- footer_buttons (list): List of button dicts (see button_group in _buttons.html)
Examples:
{{ simple_modal(
title='Confirm Delete',
content='<p>Are you sure you want to delete this deck?</p>',
footer_buttons=[
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'closeModal()'},
{'text': 'Delete', 'variant': 'danger', 'onclick': 'deleteDeck()'}
]
) }}
#}
{% macro simple_modal(title='', content='', footer_buttons=[], id='', size='md', position='center', scrollable=True, classes='', show_close=True) %}
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set position_class = 'modal-' + position -%}
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
{% if id %}id="{{ modal_id }}"{% endif %}
role="dialog"
aria-modal="true"
aria-labelledby="{{ title_id }}">
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
<div class="modal-content">
{% if title or show_close %}
<div class="modal-header">
{% if title %}
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% endif %}
{% if show_close %}
{% from '_buttons.html' import close_button %}
{{ close_button() }}
{% endif %}
</div>
{% endif %}
<div class="modal-body">
{{ content|safe }}
</div>
{% if footer_buttons %}
<div class="modal-footer">
{% from '_buttons.html' import button_group %}
{{ button_group(footer_buttons, alignment='right') }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Confirm Dialog Macro
Parameters:
- title (str): Dialog title (default: 'Confirm')
- message (str): Confirmation message (required)
- confirm_text (str): Confirm button text (default: 'Confirm')
- cancel_text (str): Cancel button text (default: 'Cancel')
- confirm_variant (str): Confirm button variant (default: 'primary')
- on_confirm (str): JavaScript handler for confirm action (required)
- on_cancel (str): JavaScript handler for cancel (default: close modal)
- classes (str): Additional CSS classes
Examples:
{{ confirm_dialog(
message='Delete this deck?',
confirm_text='Delete',
confirm_variant='danger',
on_confirm='deleteDeck(123)'
) }}
#}
{% macro confirm_dialog(title='Confirm', message='', confirm_text='Confirm', cancel_text='Cancel', confirm_variant='primary', on_confirm='', on_cancel='', classes='') %}
{{ simple_modal(
title=title,
content='<p>' + message + '</p>',
footer_buttons=[
{'text': cancel_text, 'variant': 'secondary', 'onclick': on_cancel if on_cancel else "this.closest('.modal').remove()"},
{'text': confirm_text, 'variant': confirm_variant, 'onclick': on_confirm}
],
size='sm',
classes='modal-confirm ' + classes
) }}
{% endmacro %}
{#
Form Modal Macro
Parameters: Similar to modal() plus:
- form_action (str): Form action URL (hx-post or action)
- form_method (str): 'post', 'get' (default: 'post')
- use_htmx (bool): Use HTMX for form submission (default: True)
- hx_target (str): HTMX target selector (default: 'closest .modal')
- hx_swap (str): HTMX swap strategy (default: 'outerHTML')
- submit_text (str): Submit button text (default: 'Submit')
- cancel_text (str): Cancel button text (default: 'Cancel')
- form_attrs (str): Additional form attributes
Content Blocks:
- body: Form fields (required)
Examples:
{% call form_modal(
title='Add Card',
form_action='/build/add-card',
submit_text='Add',
hx_target='#deck-list'
) %}
{% block body %}
<input type="text" name="card_name" placeholder="Card name" />
<input type="number" name="quantity" value="1" />
{% endblock %}
{% endcall %}
#}
{% macro form_modal(title='', form_action='', form_method='post', use_htmx=True, hx_target='closest .modal', hx_swap='outerHTML', submit_text='Submit', cancel_text='Cancel', size='md', classes='', form_attrs='') %}
{%- set modal_id = 'modal-form-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set all_classes = ['modal', 'modal-form', size_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
id="{{ modal_id }}"
role="dialog"
aria-modal="true"
aria-labelledby="{{ title_id }}">
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% from '_buttons.html' import close_button %}
{{ close_button() }}
</div>
<form {% if use_htmx %}hx-post="{{ form_action }}" hx-target="{{ hx_target }}" hx-swap="{{ hx_swap }}"{% else %}action="{{ form_action }}" method="{{ form_method }}"{% endif %} {{ form_attrs|safe }}>
<div class="modal-body">
{{ caller.body() }}
</div>
<div class="modal-footer">
{% from '_buttons.html' import button %}
{{ button(cancel_text, variant='secondary', onclick="this.closest('.modal').remove()") }}
{{ button(submit_text, type='submit', variant='primary') }}
</div>
</form>
</div>
</div>
{% endmacro %}
{#
Alert Modal Macro
Parameters:
- title (str): Alert title (default: 'Alert')
- message (str): Alert message (required)
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
- ok_text (str): OK button text (default: 'OK')
- on_ok (str): JavaScript handler for OK button (default: close modal)
- classes (str): Additional CSS classes
Examples:
{{ alert_modal(
title='Success',
message='Deck saved successfully!',
type='success'
) }}
{{ alert_modal(
title='Error',
message='Failed to save deck. Please try again.',
type='error'
) }}
#}
{% macro alert_modal(title='Alert', message='', type='info', ok_text='OK', on_ok='', classes='') %}
{%- set type_class = 'modal-alert-' + type -%}
{{ simple_modal(
title=title,
content='<div class="alert-icon alert-icon-' + type + '"></div><p>' + message + '</p>',
footer_buttons=[
{'text': ok_text, 'variant': 'primary', 'onclick': on_ok if on_ok else "this.closest('.modal').remove()"}
],
size='sm',
classes='modal-alert ' + type_class + ' ' + classes,
show_close=False
) }}
{% endmacro %}
{# CSS Classes Reference #}
{#
Modal Sizes:
- .modal-sm (480px max-width)
- .modal-md (620px max-width, default)
- .modal-lg (720px max-width)
- .modal-xl (960px max-width)
Modal Position:
- .modal-center (vertically centered, default)
- .modal-top (aligned to top with padding)
Modal Modifiers:
- .modal-scrollable (allows body scrolling)
- .modal-form (form-specific styling)
- .modal-confirm (confirmation dialog styling)
- .modal-alert (alert dialog styling)
- .modal-alert-info (blue theme)
- .modal-alert-success (green theme)
- .modal-alert-warning (yellow theme)
- .modal-alert-error (red theme)
Modal Structure:
- .modal (outer container, fixed positioning)
- .modal-backdrop (backdrop overlay)
- .modal-content (content box)
- .modal-header (header with title and close button)
- .modal-title (h2 title)
- .modal-body (main content area)
- .modal-footer (action buttons)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in a global JavaScript file or inline script:
// Open modal by ID
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
}
// Close modal by ID or element
function closeModal(modalOrId) {
const modal = typeof modalOrId === 'string'
? document.getElementById(modalOrId)
: modalOrId;
if (modal) {
modal.remove();
// Check if any other modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
}
// Close all modals
function closeAllModals() {
document.querySelectorAll('.modal').forEach(modal => modal.remove());
document.body.style.overflow = '';
}
#}

View file

@ -0,0 +1,399 @@
{# Panel/Tile Component Library #}
{# Usage: {{ import '_panels.html' }} then call panel macros #}
{#
Panel Container Macro
Parameters:
- title (str): Panel title (optional)
- variant (str): 'default', 'alt', 'dark', 'bordered' (default: 'default')
- padding (str): 'none', 'sm', 'md', 'lg' (default: 'md')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Content Block:
- header: Optional custom header (overrides title)
- body: Panel content (required)
- footer: Optional footer content
Examples:
{% call panel(title='Deck Stats') %}
{% block body %}
<p>Cards: 100</p>
{% endblock %}
{% endcall %}
{% call panel(variant='alt', padding='lg') %}
{% block body %}
<p>Content here</p>
{% endblock %}
{% endcall %}
#}
{% macro panel(title='', variant='default', padding='md', classes='', attrs='') %}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" {{ attrs|safe }}>
{% if caller.header is defined %}
{{ caller.header() }}
{% elif title %}
<div class="panel-header">
<h3 class="panel-title">{{ title }}</h3>
</div>
{% endif %}
<div class="panel-body">
{{ caller.body() }}
</div>
{% if caller.footer is defined %}
<div class="panel-footer">
{{ caller.footer() }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Simple Panel Macro (no block structure)
Parameters: Same as panel() plus:
- content (str): Body HTML content
Examples:
{{ simple_panel(title='Welcome', content='<p>Hello, user!</p>') }}
{{ simple_panel(content='<p>No title panel</p>', variant='alt') }}
#}
{% macro simple_panel(title='', content='', variant='default', padding='md', classes='', attrs='') %}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" {{ attrs|safe }}>
{% if title %}
<div class="panel-header">
<h3 class="panel-title">{{ title }}</h3>
</div>
{% endif %}
<div class="panel-body">
{{ content|safe }}
</div>
</div>
{% endmacro %}
{#
Info Panel Macro (with icon and optional action)
Parameters:
- icon (str): Icon HTML or character
- title (str): Panel title (required)
- content (str): Panel content (required)
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
- action_text (str): Optional action button text
- action_href (str): Action button URL
- action_onclick (str): Action button onclick handler
- classes (str): Additional CSS classes
Examples:
{{ info_panel(
icon='',
title='Setup Required',
content='Please run the setup process before building decks.',
type='info',
action_text='Run Setup',
action_href='/setup'
) }}
#}
{% macro info_panel(icon='', title='', content='', type='info', action_text='', action_href='', action_onclick='', classes='') %}
{%- set type_class = 'panel-info-' + type -%}
{%- set all_classes = ['panel', 'panel-info', type_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
<div class="panel-info-content">
{% if icon %}
<div class="panel-info-icon">{{ icon|safe }}</div>
{% endif %}
<div class="panel-info-text">
{% if title %}
<h4 class="panel-info-title">{{ title }}</h4>
{% endif %}
<div class="panel-info-message">{{ content|safe }}</div>
</div>
</div>
{% if action_text %}
<div class="panel-info-action">
{% from '_buttons.html' import button %}
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary', size='sm') }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Stat Panel Macro (for displaying key metrics)
Parameters:
- label (str): Stat label (required)
- value (str or int): Stat value (required)
- sublabel (str): Optional secondary label
- icon (str): Optional icon
- variant (str): 'default', 'primary', 'success', 'warning', 'error' (default: 'default')
- classes (str): Additional CSS classes
Examples:
{{ stat_panel('Total Cards', value=100) }}
{{ stat_panel('Mana Value', value='3.2', sublabel='Average', icon='⚡') }}
#}
{% macro stat_panel(label, value, sublabel='', icon='', variant='default', classes='') %}
{%- set variant_class = 'panel-stat-' + variant if variant != 'default' else '' -%}
{%- set all_classes = ['panel', 'panel-stat', variant_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if icon %}
<div class="panel-stat-icon">{{ icon|safe }}</div>
{% endif %}
<div class="panel-stat-content">
<div class="panel-stat-value">{{ value }}</div>
<div class="panel-stat-label">{{ label }}</div>
{% if sublabel %}
<div class="panel-stat-sublabel">{{ sublabel }}</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Collapsible Panel Macro
Parameters:
- title (str): Panel title (required)
- id (str): Panel HTML id (auto-generated if not provided)
- expanded (bool): Initially expanded (default: False)
- variant (str): Panel variant (default: 'default')
- padding (str): Panel padding (default: 'md')
- classes (str): Additional CSS classes
Content Block:
- body: Panel content (required)
Examples:
{% call collapsible_panel(title='Advanced Options', expanded=False) %}
{% block body %}
<p>Advanced settings here</p>
{% endblock %}
{% endcall %}
#}
{% macro collapsible_panel(title, id='', expanded=False, variant='default', padding='md', classes='') %}
{%- set panel_id = id if id else 'panel-' + title|lower|replace(' ', '-') -%}
{%- set content_id = panel_id + '-content' -%}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set expanded_class = 'panel-expanded' if expanded else 'panel-collapsed' -%}
{%- set all_classes = ['panel', 'panel-collapsible', variant_class, padding_class, expanded_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" id="{{ panel_id }}">
<button type="button"
class="panel-toggle"
aria-expanded="{{ 'true' if expanded else 'false' }}"
aria-controls="{{ content_id }}"
onclick="togglePanel('{{ panel_id }}')">
<span class="panel-toggle-icon"></span>
<span class="panel-title">{{ title }}</span>
</button>
<div class="panel-body panel-collapse-content"
id="{{ content_id }}"
{% if not expanded %}style="display:none;"{% endif %}>
{{ caller.body() }}
</div>
</div>
{% endmacro %}
{#
Grid Container Macro (for laying out multiple panels)
Parameters:
- columns (int or str): Number of columns (1, 2, 3, 4, 'auto') (default: 'auto')
- gap (str): Grid gap (default: '1rem')
- classes (str): Additional CSS classes
Content Block:
- body: Grid items (panels or other content)
Examples:
{% call grid_container(columns=3) %}
{% block body %}
{{ stat_panel('Stat 1', 100) }}
{{ stat_panel('Stat 2', 200) }}
{{ stat_panel('Stat 3', 300) }}
{% endblock %}
{% endcall %}
#}
{% macro grid_container(columns='auto', gap='1rem', classes='') %}
{%- set columns_class = 'panel-grid-cols-' + (columns|string) -%}
{%- set all_classes = ['panel-grid', columns_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" style="gap: {{ gap }};">
{{ caller.body() }}
</div>
{% endmacro %}
{#
Empty State Panel Macro
Parameters:
- icon (str): Icon HTML or character
- title (str): Empty state title (required)
- message (str): Empty state message (required)
- action_text (str): Optional action button text
- action_href (str): Action button URL
- action_onclick (str): Action button onclick handler
- classes (str): Additional CSS classes
Examples:
{{ empty_state_panel(
icon='📋',
title='No Decks Found',
message='You haven\'t created any decks yet. Start building your first deck!',
action_text='Build Deck',
action_href='/build'
) }}
#}
{% macro empty_state_panel(icon='', title='', message='', action_text='', action_href='', action_onclick='', classes='') %}
{%- set all_classes = ['panel', 'panel-empty-state', classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if icon %}
<div class="panel-empty-icon">{{ icon|safe }}</div>
{% endif %}
{% if title %}
<h3 class="panel-empty-title">{{ title }}</h3>
{% endif %}
{% if message %}
<p class="panel-empty-message">{{ message }}</p>
{% endif %}
{% if action_text %}
<div class="panel-empty-action">
{% from '_buttons.html' import button %}
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary') }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Loading Panel Macro
Parameters:
- message (str): Loading message (default: 'Loading...')
- spinner (bool): Show spinner animation (default: True)
- classes (str): Additional CSS classes
Examples:
{{ loading_panel() }}
{{ loading_panel(message='Building deck...', spinner=True) }}
#}
{% macro loading_panel(message='Loading...', spinner=True, classes='') %}
{%- set all_classes = ['panel', 'panel-loading', classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if spinner %}
<div class="panel-loading-spinner" aria-hidden="true"></div>
{% endif %}
<div class="panel-loading-message">{{ message }}</div>
</div>
{% endmacro %}
{# CSS Classes Reference #}
{#
Panel Base:
- .panel (base panel class)
Panel Variants:
- .panel-default (default background, var(--panel))
- .panel-alt (alternate background, var(--panel-alt))
- .panel-dark (dark background, #0f1115)
- .panel-bordered (bordered, no background)
Panel Padding:
- .panel-padding-none (no padding)
- .panel-padding-sm (0.5rem)
- .panel-padding-md (0.75rem, default)
- .panel-padding-lg (1.5rem)
Panel Structure:
- .panel-header (header section)
- .panel-title (title text, h3)
- .panel-body (content section)
- .panel-footer (footer section)
Info Panel:
- .panel-info (info panel container)
- .panel-info-info (blue theme)
- .panel-info-success (green theme)
- .panel-info-warning (yellow theme)
- .panel-info-error (red theme)
- .panel-info-content (content wrapper)
- .panel-info-icon (icon container)
- .panel-info-text (text wrapper)
- .panel-info-title (info title, h4)
- .panel-info-message (info message)
- .panel-info-action (action button wrapper)
Stat Panel:
- .panel-stat (stat panel container)
- .panel-stat-default, .panel-stat-primary, etc. (color variants)
- .panel-stat-icon (stat icon)
- .panel-stat-content (stat content wrapper)
- .panel-stat-value (stat value, large)
- .panel-stat-label (stat label)
- .panel-stat-sublabel (optional secondary label)
Collapsible Panel:
- .panel-collapsible (collapsible panel)
- .panel-expanded (expanded state)
- .panel-collapsed (collapsed state)
- .panel-toggle (toggle button)
- .panel-toggle-icon (chevron/arrow icon)
- .panel-collapse-content (collapsible content)
Panel Grid:
- .panel-grid (grid container)
- .panel-grid-cols-auto (auto columns)
- .panel-grid-cols-1, .panel-grid-cols-2, etc. (fixed columns)
Empty State:
- .panel-empty-state (empty state container)
- .panel-empty-icon (empty state icon)
- .panel-empty-title (empty state title, h3)
- .panel-empty-message (empty state message, p)
- .panel-empty-action (action button wrapper)
Loading Panel:
- .panel-loading (loading panel)
- .panel-loading-spinner (spinner animation)
- .panel-loading-message (loading message text)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in a global JavaScript file or inline script:
// Toggle collapsible panel
function togglePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-collapse-content');
const isExpanded = button.getAttribute('aria-expanded') === 'true';
// Toggle state
button.setAttribute('aria-expanded', !isExpanded);
content.style.display = isExpanded ? 'none' : 'block';
panel.classList.toggle('panel-expanded');
panel.classList.toggle('panel-collapsed');
}
#}

View file

@ -1,9 +1,9 @@
<div id="deck-summary" data-summary>
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<hr class="summary-divider" />
<h4>Deck Summary</h4>
<section style="margin-top:.5rem;">
<section class="summary-section">
<h5>Card Types</h5>
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
<div class="summary-view-controls">
<span class="muted">View:</span>
<div class="seg" role="tablist" aria-label="Type view">
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
@ -36,7 +36,7 @@
</style>
<div id="typeview-list" class="typeview">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
<div class="summary-type-heading">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
@ -85,13 +85,13 @@
{% endfor %}
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
<div class="muted mb-3">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
<div id="typeview-thumbs" class="typeview hidden">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
<div class="summary-type-heading">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
@ -111,8 +111,8 @@
{% endfor %}
{% endif %}
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
@ -124,7 +124,7 @@
</div>
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
<div class="muted mb-3">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
@ -138,40 +138,40 @@
<!-- Land Summary -->
{% set land = summary.land_summary if summary else None %}
{% if land %}
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<h5>Land Summary</h5>
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
<div class="muted summary-type-heading mb-1">
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
</div>
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
<div class="deck-metrics-wrap">
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
</div>
{% if land.dfc_cards %}
<details style="margin-top:.5rem;">
<details class="mt-2">
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
<ul class="land-breakdown-list">
{% for card in land.dfc_cards %}
{% set extra = card.adds_extra_land or card.counts_as_extra %}
{% set colors = card.colors or [] %}
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
<li class="muted land-breakdown-item">
<span class="chip"><span class="dot dot-green"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
<span>Colors: {{ colors|join(', ') if colors else '' }}</span>
{% if extra %}
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
<span class="chip land-note-chip-expand">{{ card.note or 'Adds extra land slot' }}</span>
{% else %}
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
<span class="chip land-note-chip-counts">{{ card.note or 'Counts as land slot' }}</span>
{% endif %}
{% if card.faces %}
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
<ul class="land-breakdown-subs">
{% for face in card.faces %}
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
{% set face_type = face.get('type') or '' %}
{% set mana_cost = face.get('mana_cost') %}
{% set mana_value = face.get('mana_value') %}
{% set produces = face.get('produces_mana') %}
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
<li class="land-breakdown-sub">
<span>{{ face_name }}</span>
<span>— {{ face_type }}</span>
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
@ -190,27 +190,27 @@
{% endif %}
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<summary class="combo-summary">
<span>Mana Overview</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
<span class="muted text-xs font-normal ml-2">(pips • sources • curve)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .5rem 0;">Mana Overview</h5>
<div class="analytics-content mt-3">
<h5 class="mt-0 mb-2">Mana Overview</h5>
{% set deck_colors = summary.colors or [] %}
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
<div class="mana-row">
<!-- Pips Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
<div class="mana-panel">
<div class="muted mana-panel-heading">Mana Pips (non-lands)</div>
{% set pd = summary.pip_distribution %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="chart-bars">
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;" class="chart-column">
<div class="chart-column">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
@ -224,14 +224,14 @@
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" class="chart-svg" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<div class="muted mt-1">{{ color }}</div>
</div>
{% endfor %}
</div>
@ -241,10 +241,10 @@
</div>
<!-- Sources Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
<div class="muted" style="font-weight:600;">Mana Sources</div>
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
<div class="mana-panel">
<div class="flex items-center justify-between gap-3 mb-1">
<div class="muted mana-panel-heading">Mana Sources</div>
<label class="muted text-xs form-label-icon">
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
</label>
</div>
@ -261,7 +261,7 @@
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="sources-bars chart-bars">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
@ -273,31 +273,31 @@
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<div class="chart-column" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" class="chart-svg" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<div class="muted mt-1">{{ color }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
<div class="muted mt-1">Total sources: {{ mg.total_sources or 0 }}</div>
{% else %}
<div class="muted">No mana source data.</div>
{% endif %}
</div>
<!-- Curve Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
<div class="mana-panel">
<div class="muted mana-panel-heading">Mana Curve (non-lands)</div>
{% set mc = summary.mana_curve %}
{% if mc %}
{% set ts = mc.total_spells or 0 %}
{% set denom = (ts if ts and ts > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="chart-bars">
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
@ -308,18 +308,18 @@
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<div style="text-align:center;" class="chart-column">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<div class="chart-column">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" class="chart-svg" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
<div class="muted mt-1">{{ label }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
<div class="muted mt-1">Total spells: {{ mc.total_spells or 0 }}</div>
{% else %}
<div class="muted">No curve data.</div>
{% endif %}
@ -330,17 +330,17 @@
</section>
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<summary class="combo-summary">
<span>Test Hand</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
<span class="muted text-xs font-normal ml-2">(draw 7 random cards)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
<div class="analytics-content mt-3">
<h5 class="flex items-center gap-3 flex-wrap mt-0 mb-1">Test Hand
<span class="muted text-xs font-normal">Draw 7 at random (no repeats except for basic lands).</span>
</h5>
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem;">
<div class="flex gap-2 items-center flex-wrap mb-2">
<button type="button" id="btn-new-hand">New Hand</button>
</div>
<div class="stack-wrap hand-row-overlap" id="test-hand">
@ -440,7 +440,7 @@
if (mid >= 2 && Math.abs(diff - (mid - 1)) < 0.001) { y += 2; }
div.style.setProperty('--ty', y + 'px');
div.innerHTML = (
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
'<img src="/api/images/normal/' + encodeURIComponent(name) + '" alt="' + name + '" data-card-name="' + name + '" />'
);
grid.appendChild(div);
});

View file

@ -109,8 +109,8 @@
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
<div class="commander-thumb" style="flex:0 0 auto;">
<img
src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w"
src="{{ commander|card_image('small') }}"
srcset="{{ commander|card_image('small') }} 160w, {{ commander|card_image('normal') }} 488w"
sizes="(max-width: 600px) 120px, 160px"
alt="{{ commander }} image"
width="160" height="220"

View file

@ -1,5 +1,51 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% block content %}
<style>
/* Setup page-specific styling */
#content details > summary {
color: #3b82f6;
cursor: pointer;
font-weight: 500;
}
#content details > summary:hover {
color: #60a5fa;
}
#content details > div {
background: #050607 !important;
border-color: #1e293b !important;
}
#content .muted {
color: #94a3b8;
}
/* Make all buttons on this page blue */
#content button,
#content input[type="submit"] {
background: #3b82f6 !important;
border-color: #3b82f6 !important;
color: white !important;
}
#content button:hover,
#content input[type="submit"]:hover {
background: #2563eb !important;
border-color: #2563eb !important;
}
#content button:active,
#content input[type="submit"]:active {
background: #1d4ed8 !important;
}
/* Progress bars */
#content [id$="-progress-bar"] {
background: #0a0c10 !important;
}
/* Log output areas */
#content pre {
background: #030405 !important;
border-color: #1e293b !important;
}
</style>
<section>
<h2>Setup / Tagging</h2>
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
@ -29,22 +75,42 @@
Download pre-tagged card database and similarity cache from GitHub (updated weekly).
<strong>Note:</strong> A fresh local tagging run will be most up-to-date with the latest card data.
</p>
<button type="button" class="action-btn" onclick="downloadFromGitHub()" id="btn-download-github">
Download from GitHub
</button>
{{ button('Download from GitHub', variant='priamry', onclick='downloadFromGitHub()', attrs='id="btn-download-github"') }}
<div id="download-status" class="muted" style="margin-top:.5rem; display:none;"></div>
</div>
</details>
{% if image_cache_enabled %}
<details style="margin-top:1rem;">
<summary>Download Card Images (Optional)</summary>
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<p class="muted" style="margin:0 0 .75rem 0; font-size:.9rem;">
Download card images from Scryfall CDN for faster loading and offline use.
<strong>Note:</strong> Requires ~3-6 GB disk space and 1-2 hours download time (~30k cards).
</p>
<div id="image-cache-status" style="margin-bottom:.75rem;">
<div class="muted">Status:</div>
<div id="image-status-line" style="margin-top:.25rem;">Checking…</div>
<div class="muted" id="image-stats-line" style="margin-top:.25rem; display:none;"></div>
</div>
{{ button('Download Card Images', variant='priamry', onclick='downloadCardImages()', attrs='id="btn-download-images"') }}
<div id="image-download-status" class="muted" style="margin-top:.5rem; display:none;"></div>
<div id="image-progress-bar" style="margin-top:.5rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="image-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
</div>
</div>
</details>
{% endif %}
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
<button type="submit" id="btn-start-setup" class="action-btn">Run Setup/Tagging</button>
{{ button('Run Setup/Tagging', variant='primary', type='submit', attrs='id="btn-start-setup"') }}
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
<input type="checkbox" id="chk-force" checked /> Force run
</label>
</form>
<form method="get" action="/setup/running?start=1&force=1">
<button type="submit" class="action-btn">Open Progress Page</button>
{{ button('Open Progress Page', variant='primary', type='submit') }}
</form>
</div>
@ -58,7 +124,7 @@
</div>
</details>
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button>
{{ button('Refresh Themes Only', variant='priamry', onclick='refreshThemes()', attrs='id="btn-refresh-themes"') }}
</div>
{% if similarity_enabled %}
@ -72,7 +138,7 @@
</div>
</details>
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<button type="button" id="btn-build-similarity" class="action-btn" onclick="buildSimilarityCache()">Build Similarity Cache</button>
{{ button('Build Similarity Cache', variant='priamry', onclick='buildSimilarityCache()', attrs='id="btn-build-similarity"') }}
<label class="muted" style="align-self:center; font-size:.85rem;">
<input type="checkbox" id="chk-skip-download" /> Skip GitHub download (build locally)
</label>
@ -85,7 +151,7 @@
// Minimal styling helper to unify button widths
try {
var style = document.createElement('style');
style.textContent = '.action-btn{min-width:180px;}';
style.textContent = '.btn{min-width:180px;}';
document.head.appendChild(style);
} catch(e){}
function update(data){
@ -219,6 +285,137 @@
.then(function(){ setTimeout(function(){ pollThemes(); if(btn) btn.disabled=false; }, 1200); })
.catch(function(){ if(btn) btn.disabled=false; });
};
// Card image download functions
function pollImageStatus(){
fetch('/api/images/status', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(function(data){
var statusLine = document.getElementById('image-status-line');
var statsLine = document.getElementById('image-stats-line');
var downloadStatus = document.getElementById('image-download-status');
var progressBar = document.getElementById('image-progress-bar');
var progressBarInner = document.getElementById('image-progress-bar-inner');
if (!statusLine) return;
if (data.running) {
// Download in progress
var phase = data.phase || 'unknown';
var message = data.message || 'Downloading...';
var percentage = data.percentage || 0;
statusLine.textContent = 'Download in progress';
statusLine.style.color = '#3b82f6';
if (downloadStatus) {
downloadStatus.style.display = '';
downloadStatus.textContent = message + ' (' + percentage + '%)';
}
if (progressBar && progressBarInner) {
progressBar.style.display = '';
progressBarInner.style.width = percentage + '%';
}
} else if (data.stats) {
// Show cache statistics
var stats = data.stats;
if (stats.enabled === false) {
statusLine.textContent = 'Image caching disabled';
statusLine.style.color = '#94a3b8';
if (statsLine) statsLine.style.display = 'none';
} else {
var totalCount = 0;
var totalSizeMB = 0;
if (stats.small) {
totalCount += stats.small.count || 0;
totalSizeMB += stats.small.size_mb || 0;
}
if (stats.normal) {
totalCount += stats.normal.count || 0;
totalSizeMB += stats.normal.size_mb || 0;
}
if (totalCount > 0) {
statusLine.textContent = 'Cache exists';
statusLine.style.color = '#34d399';
if (statsLine) {
statsLine.style.display = '';
statsLine.textContent = totalCount.toLocaleString() + ' images cached • ' + totalSizeMB.toFixed(1) + ' MB';
}
} else {
statusLine.textContent = 'No images cached';
statusLine.style.color = '#94a3b8';
if (statsLine) statsLine.style.display = 'none';
}
}
// Hide download progress
if (downloadStatus) downloadStatus.style.display = 'none';
if (progressBar) progressBar.style.display = 'none';
}
})
.catch(function(){
var statusLine = document.getElementById('image-status-line');
if (statusLine) {
statusLine.textContent = 'Status unavailable';
statusLine.style.color = '#94a3b8';
}
});
}
window.downloadCardImages = function(){
var btn = document.getElementById('btn-download-images');
var statusEl = document.getElementById('image-download-status');
if (!confirm('Download card images from Scryfall? This will download ~30k images (~3-6 GB) and may take 1-2 hours.')) {
return;
}
if (btn) btn.disabled = true;
if (statusEl) {
statusEl.style.display = '';
statusEl.textContent = 'Starting download...';
}
fetch('/api/images/download', { method: 'POST' })
.then(function(r){
if (!r.ok) {
return r.json().then(function(data){
throw new Error(data.message || 'Download failed');
});
}
return r.json();
})
.then(function(data){
if (statusEl) {
statusEl.style.color = '#34d399';
statusEl.textContent = '✓ ' + (data.message || 'Download started');
}
// Poll status every 2 seconds while downloading
var pollCount = 0;
var imagePoll = setInterval(function(){
pollImageStatus();
pollCount++;
// Stop intensive polling after 2 hours (3600 polls)
if (pollCount > 3600) clearInterval(imagePoll);
}, 2000);
setTimeout(function(){
if (btn) btn.disabled = false;
}, 3000);
})
.catch(function(err){
if (statusEl) {
statusEl.style.color = '#f87171';
statusEl.textContent = '✗ ' + (err.message || 'Download failed');
}
if (btn) btn.disabled = false;
});
};
function rapidPoll(times, delay){
var i = 0;
function tick(){
@ -395,6 +592,10 @@
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
{% endif %}
// Initialize image status polling
pollImageStatus();
setInterval(pollImageStatus, 10000); // Poll every 10s
setInterval(poll, 3000);
poll();
pollThemes();

View file

@ -2,15 +2,15 @@
{% block content %}
<h2>Theme Catalog (Simple)</h2>
<div id="theme-catalog-simple">
<div style="display:flex; gap:.75rem; flex-wrap:wrap; margin-bottom:.85rem; align-items:flex-end;">
<div style="flex:1; min-width:220px; position:relative;">
<label style="font-size:11px; display:block; opacity:.7;">Search</label>
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" style="width:100%;" autocomplete="off" />
<div id="theme-search-results" class="search-suggestions" style="position:absolute; top:100%; left:0; right:0; background:var(--panel); border:1px solid var(--border); border-top:none; z-index:25; display:none; max-height:300px; overflow:auto; border-radius:0 0 8px 8px;"></div>
<div class="flex gap-3 flex-wrap mb-3.5 items-end">
<div class="flex-1 min-w-[220px] relative">
<label class="text-[11px] block opacity-70">Search</label>
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" class="w-full" autocomplete="off" />
<div id="theme-search-results" class="search-suggestions"></div>
</div>
<div style="min-width:160px;">
<label style="font-size:11px; display:block; opacity:.7;">Popularity</label>
<select id="pop-filter" style="width:100%; font-size:13px;">
<div class="min-w-[160px]">
<label class="text-[11px] block opacity-70">Popularity</label>
<select id="pop-filter" class="w-full text-[13px]">
<option value="">All</option>
<option>Very Common</option>
<option>Common</option>
@ -19,26 +19,20 @@
<option>Rare</option>
</select>
</div>
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
<button id="clear-search" class="btn btn-ghost text-xs" hidden>Clear</button>
</div>
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
<div id="quick-popularity" class="flex gap-1.5 flex-wrap mb-2">
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %}
<button class="btn btn-ghost pop-chip" data-pop="{{ b }}" style="font-size:11px; padding:2px 8px;">{{ b }}</button>
<button class="btn btn-ghost pop-chip text-[11px] px-2 py-0.5" data-pop="{{ b }}">{{ b }}</button>
{% endfor %}
</div>
<div id="active-filters" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:.55rem; font-size:11px;"></div>
<div id="active-filters" class="flex gap-1.5 flex-wrap mb-2 text-[11px]"></div>
<div id="theme-results" aria-live="polite" aria-busy="true">
<div style="display:flex; flex-direction:column; gap:8px;">
{% for i in range(6) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
<div class="flex flex-col gap-2">
{% for i in range(6) %}<div class="skeleton-card"></div>{% endfor %}
</div>
</div>
</div>
<style>
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); transition:background .15s ease; }
.search-suggestions a:last-child { border-bottom:none; }
.search-suggestions a:hover, .search-suggestions a.selected { background:var(--hover); }
.search-suggestions a.selected { border-left:3px solid var(--ring); padding-left:calc(.6rem - 3px); }
</style>
<script>
(function(){
const input = document.getElementById('theme-search');
@ -60,8 +54,8 @@
}
function addChip(label, remover){
const span=document.createElement('span');
span.style.cssText='background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; display:inline-flex; align-items:center; gap:6px;';
span.innerHTML='<span>'+label+'</span><button style="background:none; border:none; cursor:pointer; font-size:12px;" aria-label="Remove">×</button>';
span.className='filter-chip';
span.innerHTML='<span>'+label+'</span><button class="filter-chip-remove" aria-label="Remove">×</button>';
span.querySelector('button').addEventListener('click', remover);
activeFilters.appendChild(span);
}

View file

@ -1,11 +1,11 @@
{% if theme %}
<div class="theme-detail-card">
{% if standalone_page %}
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost" style="font-size:11px; padding:2px 6px;">← Catalog</a></div>
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost text-[11px] px-1.5 py-0.5">← Catalog</a></div>
{% endif %}
<h3 id="theme-detail-heading-{{ theme.id }}" tabindex="-1">{{ theme.theme }}
{% if diagnostics and yaml_available %}
<a href="/themes/yaml/{{ theme.id }}" target="_blank" style="font-size:11px; font-weight:400; margin-left:.5rem;">(YAML)</a>
<a href="/themes/yaml/{{ theme.id }}" target="_blank" class="text-[11px] font-normal ml-2">(YAML)</a>
{% endif %}
</h3>
{% if theme.description %}
@ -17,7 +17,7 @@
<p class="desc" data-fallback-desc="1">No description.</p>
{% endif %}
{% endif %}
<div style="font-size:12px; margin-bottom:.5rem; display:flex; gap:8px; flex-wrap:wrap;">
<div class="text-xs mb-2 flex gap-2 flex-wrap">
{% if theme.popularity_bucket %}<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ theme.popularity_bucket }}" aria-label="Popularity bucket: {{ theme.popularity_bucket }}">{{ theme.popularity_bucket }}</span>{% endif %}
{% if diagnostics and theme.editorial_quality %}<span class="theme-badge badge-quality-{{ theme.editorial_quality }}" title="Editorial quality: {{ theme.editorial_quality }}" aria-label="Editorial quality: {{ theme.editorial_quality }}">{{ theme.editorial_quality }}</span>{% endif %}
{% if diagnostics and theme.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %}
@ -29,44 +29,44 @@
</div>
{% if diagnostics %}
{% if not uncapped and theme.uncapped_synergies %}
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" class="mt-2">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
{% elif uncapped %}
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Hide Uncapped</button>
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" class="mt-2">Hide Uncapped</button>
{% if theme.uncapped_synergies %}
<div class="theme-synergies" style="margin-top:.4rem;">
<div class="theme-synergies mt-1.5">
{% for s in theme.uncapped_synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
</div>
{% endif %}
{% endif %}
{% endif %}
</div>
<div class="examples" style="margin-top:.75rem;">
<h4 style="margin-bottom:.4rem;">Example Cards</h4>
<div class="example-card-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
<div class="examples mt-3">
<h4 class="mb-1.5">Example Cards</h4>
<div class="example-card-grid grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3.5">
{% if theme.example_cards %}
{% for c in theme.example_cards %}
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
<div class="ex-card card-sample text-center" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
<div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
</div>
{% endfor %}
{% else %}
<div style="font-size:12px; opacity:.7;">No curated example cards.</div>
<div class="text-xs opacity-70">No curated example cards.</div>
{% endif %}
</div>
<h4 style="margin:.9rem 0 .4rem;">Example Commanders</h4>
<div class="example-commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
<h4 class="my-3.5 mb-1.5">Example Commanders</h4>
<div class="example-commander-grid grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3.5">
{% if theme.example_commanders %}
{% for c in theme.example_commanders %}
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
<div class="ex-commander commander-cell text-center" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
<div class="text-[11px] mt-1 font-semibold whitespace-nowrap overflow-hidden text-ellipsis card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
</div>
{% endfor %}
{% else %}
<div style="font-size:12px; opacity:.7;">No curated commander examples.</div>
<div class="text-xs opacity-70">No curated commander examples.</div>
{% endif %}
</div>
</div>
@ -74,10 +74,6 @@
{% else %}
<div class="empty">Theme not found.</div>
{% endif %}
<style>
.card-ref { cursor:pointer; text-decoration:underline dotted; }
.card-ref:hover { color:var(--accent); }
</style>
<script>
// Accessibility: automatically move focus to the detail heading after the fragment is swapped in
(function(){
@ -97,7 +93,7 @@
// Replace fuzzy param only if it still contains the annotated portion
var before = decodeURIComponent((current.split('fuzzy=')[1]||'').split('&')[0] || '');
if(before && before !== base){
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
img.src = '/api/images/small/' + encodeURIComponent(base);
}
}
}

View file

@ -1,44 +1,46 @@
{% if items %}
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.5rem; font-size:12px;">
<div class="flex justify-between items-center mb-2 text-xs">
<div>Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}</div>
<div style="display:flex; gap:.4rem;">
<div class="flex gap-2">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">« Prev</button>
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">« Prev</button>{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">Next »</button>
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">Next »</button>{% endif %}
</div>
</div>
<ul class="theme-simple-list" style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.65rem;">
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
{% for it in items %}
<li style="padding:.6rem .75rem; border:1px solid var(--border); border-radius:8px; background:var(--panel-alt);">
<a href="/themes/{{ it.id }}" style="font-weight:600; font-size:14px; text-decoration:none; color:var(--text);">{{ it.theme }}</a>
{% if it.short_description %}<div style="font-size:12px; opacity:.85; margin-top:2px;">{{ it.short_description }}</div>{% endif %}
<li class="theme-list-card">
<a href="/themes/{{ it.id }}" class="font-semibold text-sm no-underline text-[var(--text)]">{{ it.theme }}</a>
{% if it.short_description %}<div class="text-xs opacity-85 mt-0.5">{{ it.short_description }}</div>{% endif %}
</li>
{% endfor %}
</ul>
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.75rem; font-size:12px;">
<div class="flex justify-between items-center mt-3 text-xs">
<div>Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}</div>
<div style="display:flex; gap:.4rem;">
<div class="flex gap-2">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">« Prev</button>
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">« Prev</button>{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">Next »</button>
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">Next »</button>{% endif %}
</div>
</div>
{% else %}
{% if total == 0 %}
<div class="empty" style="font-size:13px;">No themes found.</div>
<div class="empty text-xs">No themes found.</div>
{% else %}
<div style="display:flex; flex-direction:column; gap:8px;">
{% for i in range(8) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
<div class="flex flex-col gap-2">
{% for i in range(8) %}<div class="h-12 rounded-lg skeleton-shimmer"></div>{% endfor %}
</div>
{% endif %}
{% endif %}
<style>
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
.theme-simple-list li:hover { background:var(--hover); }
@keyframes shimmer {0%{background-position:0 0;}100%{background-position:-200% 0;}}
.skeleton-shimmer { background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation:shimmer 1.2s ease-in-out infinite; }
.theme-list-card { background:var(--panel); padding:0.6rem 0.75rem; border:1px solid var(--border); border-radius:8px; box-shadow:0 1px 3px rgba(0,0,0,0.2); transition:background-color 0.15s ease; }
.theme-list-card:hover { background:var(--hover); }
</style>

View file

@ -1,30 +1,30 @@
{% if preview %}
<div class="preview-modal-content theme-preview-expanded{% if minimal %} minimal-variant{% endif %}">
{% if not minimal %}
<div class="preview-header" style="display:flex; justify-content:space-between; align-items:center; gap:1rem;">
<h3 style="margin:0; font-size:16px;" data-preview-heading>{{ preview.theme }}</h3>
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost" style="font-size:12px; line-height:1;">Close ✕</button>
<div class="preview-header">
<h3 data-preview-heading>{{ preview.theme }}</h3>
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost">Close ✕</button>
</div>
{% if preview.stub %}<div class="note note-stub">Stub sample (placeholder logic)</div>{% endif %}
<div class="preview-controls" style="display:flex; gap:1rem; align-items:center; margin:.5rem 0 .75rem; font-size:11px;">
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="reasons-toggle" checked/> Reasons <span style="opacity:.55; font-size:10px; cursor:help;" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="show-duplicates-toggle"/> Show Collapsed Duplicates</label>
<span id="preview-status" aria-live="polite" style="opacity:.65;"></span>
<div class="preview-controls">
<label><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
<label><input type="checkbox" id="reasons-toggle" checked/> Reasons <span class="help-icon" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
<label><input type="checkbox" id="show-duplicates-toggle"/> Show Collapsed Duplicates</label>
<span id="preview-status" aria-live="polite"></span>
</div>
<details id="preview-rationale" class="preview-rationale" style="margin:.25rem 0 .85rem; font-size:11px; background:var(--panel-alt); border:1px solid var(--border); padding:.55rem .7rem; border-radius:8px;">
<summary style="cursor:pointer; font-weight:600; letter-spacing:.05em;">Commander Overlap & Diversity Rationale</summary>
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-top:.4rem;">
<button type="button" class="btn btn-ghost" style="font-size:10px; padding:4px 8px;" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
<span id="hover-compact-indicator" style="font-size:10px; opacity:.7;">Mode: <span data-mode>normal</span></span>
<details id="preview-rationale" class="preview-rationale">
<summary>Commander Overlap & Diversity Rationale</summary>
<div class="preview-rationale-controls">
<button type="button" class="btn btn-ghost" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
<span id="hover-compact-indicator">Mode: <span data-mode>normal</span></span>
</div>
<ul id="rationale-points" style="margin:.5rem 0 0 .9rem; padding:0; list-style:disc; line-height:1.35;">
<ul id="rationale-points">
{% if preview.commander_rationale and preview.commander_rationale|length > 0 %}
{% for r in preview.commander_rationale %}
<li>
<strong>{{ r.label }}</strong>: {{ r.value }}
{% if r.detail %}<span style="opacity:.75;">({{ r.detail|join(', ') }})</span>{% endif %}
{% if r.instances %}<span style="opacity:.65;"> ({{ r.instances }} instances)</span>{% endif %}
{% if r.detail %}<span class="detail">({{ r.detail|join(', ') }})</span>{% endif %}
{% if r.instances %}<span class="instances"> ({{ r.instances }} instances)</span>{% endif %}
</li>
{% endfor %}
{% else %}
@ -33,55 +33,55 @@
</ul>
</details>
{% endif %}
<div class="two-col" style="display:grid; grid-template-columns: 1fr 480px; gap:1.25rem; align-items:start; position:relative;" role="group" aria-label="Theme preview cards and commanders">
<div class="col-divider" style="position:absolute; top:0; bottom:0; left:calc(100% - 480px - .75rem); width:1px; background:var(--border); opacity:.55;"></div>
<div class="preview-two-col" role="group" aria-label="Theme preview cards and commanders">
<div class="preview-col-divider"></div>
<div class="col-left">
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Cards</h4>{% else %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Sampled Synergy Cards</h4>{% endif %}{% endif %}
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}" data-pin-scope="{{ preview.theme_id }}">
{% if not minimal %}{% if not suppress_curated %}<h4 class="preview-section-header">Example Cards</h4>{% else %}<h4 class="preview-section-header">Sampled Synergy Cards</h4>{% endif %}{% endif %}
<hr class="preview-section-hr" />
<div class="cards-flow" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}" data-pin-scope="{{ preview.theme_id }}">
{% set inserted = {'examples': False, 'curated_synergy': False, 'payoff': False, 'enabler_support': False, 'wildcard': False} %}
{% for c in preview.sample if (not suppress_curated and ('example' in c.roles or 'curated_synergy' in c.roles)) or 'payoff' in c.roles or 'enabler' in c.roles or 'support' in c.roles or 'wildcard' in c.roles %}
{% if c.dup_collapsed %}{% set dup_class = ' is-collapsed-duplicate' %}{% else %}{% set dup_class = '' %}{% endif %}
{% set primary = c.roles[0] if c.roles else '' %}
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.25rem;">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator" data-group="curated_synergy" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator" data-group="payoff" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator" data-group="enabler_support" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator" data-group="wildcard" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator mt-larger" data-group="curated_synergy">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator mt-larger" data-group="payoff">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator mt-larger" data-group="enabler_support">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator mt-larger" data-group="wildcard">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
{% set overlaps = [] %}
{% if preview.synergies_used and c.tags %}
{% for tg in c.tags %}{% if tg in preview.synergies_used %}{% set _ = overlaps.append(tg) %}{% endif %}{% endfor %}
{% endif %}
<div class="card-sample{{ dup_class }}{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}" {% if c.dup_group_size %}data-dup-group-size="{{ c.dup_group_size }}"{% endif %} {% if c.dup_anchor %}data-dup-anchor="1"{% endif %} {% if c.dup_collapsed %}data-dup-collapsed="1" data-dup-anchor-name="{{ c.dup_anchor_name }}"{% endif %}>
<div class="thumb-wrap" style="position:relative;">
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<div class="card-sample{{ dup_class }}{% if overlaps %} has-overlap{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}" {% if c.dup_group_size %}data-dup-group-size="{{ c.dup_group_size }}"{% endif %} {% if c.dup_anchor %}data-dup-anchor="1"{% endif %} {% if c.dup_collapsed %}data-dup-collapsed="1" data-dup-anchor-name="{{ c.dup_anchor_name }}"{% endif %}>
<div class="thumb-wrap">
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="{{ c.name|card_image('small') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
<span class="role-chip role-{{ c.roles[0] if c.roles }}" title="Primary role: {{ c.roles[0] if c.roles }}">{{ c.roles[0][0]|upper if c.roles }}</span>
{% if overlaps %}<span class="overlap-badge" title="Synergy overlaps: {{ overlaps|join(', ') }}">{{ overlaps|length }}</span>{% endif %}
{% if c.dup_anchor and c.dup_group_size and c.dup_group_size > 1 %}<span class="dup-badge" title="{{ c.dup_group_size - 1 }} similar cards collapsed" style="position:absolute; bottom:4px; right:4px; background:#4b5563; color:#fff; font-size:10px; padding:2px 5px; border-radius:10px;">+{{ c.dup_group_size - 1 }}</span>{% endif %}
<button type="button" class="pin-btn" aria-label="Pin card" title="Pin card" data-pin-btn style="position:absolute; top:4px; right:4px; background:rgba(0,0,0,0.55); color:#fff; border:1px solid var(--border); border-radius:6px; font-size:10px; padding:2px 5px; cursor:pointer;"></button>
{% if c.dup_anchor and c.dup_group_size and c.dup_group_size > 1 %}<span class="dup-badge" title="{{ c.dup_group_size - 1 }} similar cards collapsed">+{{ c.dup_group_size - 1 }}</span>{% endif %}
<button type="button" class="pin-btn" aria-label="Pin card" title="Pin card" data-pin-btn></button>
</div>
<div class="meta" style="font-size:12px; margin-top:2px;">
<div class="ci-ribbon" aria-label="Color identity" style="display:flex; gap:2px; margin-bottom:2px; min-height:10px;"></div>
<div class="nm" style="font-weight:600; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ c.name }}">{{ c.name }}</div>
<div class="mana-line" aria-label="Mana Cost" style="min-height:14px; display:flex; flex-wrap:wrap; gap:2px; font-size:10px;"></div>
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}" style="font-size:9px; letter-spacing:.5px; text-transform:uppercase; opacity:.7;">{{ c.rarity }}</div>{% endif %}
<div class="role" style="opacity:.75; font-size:11px; display:flex; flex-wrap:wrap; gap:3px;">
<div class="meta">
<div class="ci-ribbon" aria-label="Color identity"></div>
<div class="nm" title="{{ c.name }}">{{ c.name }}</div>
<div class="mana-line" aria-label="Mana Cost"></div>
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}">{{ c.rarity }}</div>{% endif %}
<div class="role">
{% for r in c.roles %}<span class="mini-badge role-{{ r }}" title="{{ r }} role">{{ r[0]|upper }}</span>{% endfor %}
</div>
{% if c.reasons %}<div class="reasons" data-reasons-block style="font-size:9px; opacity:.55; line-height:1.15;" title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
{% if c.reasons %}<div class="reasons" data-reasons-block title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% set has_synth = false %}
{% for c in preview.sample %}{% if 'synthetic' in c.roles %}{% set has_synth = true %}{% endif %}{% endfor %}
{% if has_synth %}
<div style="flex-basis:100%; height:0;"></div>
<div class="full-width-spacer"></div>
{% for c in preview.sample %}
{% if 'synthetic' in c.roles %}
<div class="card-sample synthetic" style="width:230px; border:1px dashed var(--border); padding:8px; border-radius:10px; background:var(--panel-alt);" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
<div style="font-size:12px; font-weight:600; line-height:1.2;">{{ c.name }}</div>
<div style="font-size:11px; opacity:.8;">{{ c.roles|join(', ') }}</div>
{% if c.reasons %}<div style="font-size:10px; margin-top:2px; opacity:.6; line-height:1.15;">{{ c.reasons|join(', ') }}</div>{% endif %}
<div class="card-sample synthetic" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
<div class="name">{{ c.name }}</div>
<div class="roles">{{ c.roles|join(', ') }}</div>
{% if c.reasons %}<div class="reasons-text">{{ c.reasons|join(', ') }}</div>{% endif %}
</div>
{% endif %}
{% endfor %}
@ -89,10 +89,10 @@
</div>
</div>
<div class="col-right">
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Commanders</h4>{% else %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Synergy Commanders</h4>{% endif %}{% endif %}
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
{% if not minimal %}{% if not suppress_curated %}<h4 class="preview-section-header">Example Commanders</h4>{% else %}<h4 class="preview-section-header">Synergy Commanders</h4>{% endif %}{% endif %}
<hr class="preview-section-hr" />
{% if example_commanders and not suppress_curated %}
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
<div class="commander-grid">
{% for name in example_commanders %}
{# Derive per-commander overlaps; still show full theme synergy set in data-tags for context #}
{% set base = name %}
@ -104,22 +104,22 @@
{% endif %}
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
<div class="commander-cell" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<div class="commander-name" style="font-size:13px; text-align:center; line-height:1.35; font-weight:600; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
<div class="commander-cell" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="{{ base|card_image('small') }}" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
<div class="commander-name" title="{{ name }}">{{ name }}</div>
</div>
{% endfor %}
</div>
{% elif not suppress_curated %}
<div style="font-size:11px; opacity:.7;">No curated commander examples.</div>
<div class="no-commanders-message">No curated commander examples.</div>
{% endif %}
{% if synergy_commanders %}
<div style="margin-top:1rem;">
<div style="display:flex; align-items:center; gap:.4rem; margin-bottom:.4rem;">
<h5 style="margin:0; font-size:11px; letter-spacing:.05em; text-transform:uppercase; opacity:.75;">Synergy Commanders</h5>
<span title="Derived from synergy overlap heuristics" style="background:var(--panel-alt); border:1px solid var(--border); border-radius:10px; padding:2px 6px; font-size:10px; line-height:1;">Derived</span>
<div class="synergy-commanders-section">
<div class="synergy-commanders-header">
<h5>Synergy Commanders</h5>
<span class="derived-badge" title="Derived from synergy overlap heuristics">Derived</span>
</div>
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
<div class="commander-grid">
{% for name in synergy_commanders[:8] %}
{# Strip any appended ' - Synergy (...' suffix for image lookup while preserving display #}
{% set base = name %}
@ -131,9 +131,9 @@
{% endif %}
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
<div class="commander-cell synergy" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<div class="commander-name" style="font-size:12px; text-align:center; line-height:1.3; font-weight:500; opacity:.92; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
<div class="commander-cell synergy" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="{{ base|card_image('small') }}" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
<div class="commander-name" title="{{ name }}">{{ name }}</div>
</div>
{% endfor %}
</div>
@ -141,16 +141,16 @@
{% endif %}
</div>
</div>
{% if not minimal %}<div style="margin-top:1rem; font-size:10px; opacity:.65; line-height:1.4;">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
{% if not minimal %}<div class="preview-help-text">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
</div>
{% else %}
<div class="preview-modal-content">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="sk-bar" style="height:16px; width:200px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-bar" style="height:16px; width:60px; background:var(--hover); border-radius:4px;"></div>
<div class="preview-modal-content preview-skeleton">
<div class="sk-header">
<div class="sk-bar title"></div>
<div class="sk-bar close"></div>
</div>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-top:1rem;">
{% for i in range(8) %}<div style="width:230px; height:327px; background:var(--hover); border-radius:10px;"></div>{% endfor %}
<div class="sk-cards">
{% for i in range(8) %}<div class="sk-card"></div>{% endfor %}
</div>
</div>
{% endif %}
@ -352,7 +352,7 @@
var base = m[1].trim();
if(base && base !== n){
img.setAttribute('data-card-name', base);
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
img.src = '/api/images/small/' + encodeURIComponent(base);
}
// Attempt to derive overlaps if not already present
if(!img.getAttribute('data-overlaps')){