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

@ -106,6 +106,9 @@ WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1"
WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4"
WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0"
# Card Image Caching (optional, uses Scryfall bulk data API)
CACHE_CARD_IMAGES=1 # dockerhub: CACHE_CARD_IMAGES="1" (1=download images to card_files/images/, 0=fetch from Scryfall API on demand)
# Build Stage Ordering
WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill

11
.gitignore vendored
View file

@ -9,6 +9,7 @@
RELEASE_NOTES.md
test.py
test_*.py
!test_exclude_cards.txt
!test_include_exclude_config.json
@ -41,3 +42,13 @@ logs/*
!logs/perf/
logs/perf/*
!logs/perf/theme_preview_warm_baseline.json
# Node.js and build artifacts
node_modules/
code/web/static/js/
code/web/static/styles.css
*.js.map
# Keep TypeScript sources and Tailwind CSS input
!code/web/static/ts/
!code/web/static/tailwind.css

View file

@ -9,14 +9,27 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
- **Build X and Compare** feature: Build multiple decks with same configuration and compare results side-by-side
- Build 1-10 decks in parallel to see variance from card selection randomness
- Real-time progress tracking with dynamic time estimates based on color count
- Comparison view with card overlap statistics and individual build summaries
- Smart filtering excludes guaranteed cards (basics, staples) from "Most Common Cards"
- Card hover support throughout comparison interface
- Rebuild button to rerun same configuration
- Export all decks as ZIP archive
- **Card Image Caching**: Optional local image cache for faster card display
- Downloads card images from Scryfall bulk data (respects API guidelines)
- Graceful fallback to Scryfall API for uncached images
- Enabled via `CACHE_CARD_IMAGES=1` environment variable
- Integrated with setup/tagging process
- Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness)
- **Component Library**: Living documentation of reusable UI components at `/docs/components`
- Interactive examples of all buttons, modals, forms, cards, and panels
- Jinja2 macros for consistent component usage
- Component partial templates for reuse across pages
### Changed
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
- Tailwind CSS v3 with custom MTG color palette
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Docker Build Optimization**: Improved developer experience
- Hot reload enabled for templates and static files
- Volume mounts for rapid iteration without rebuilds
- **Template Modernization**: Migrated templates to use component system
- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck
- Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%)
- 10% bonus for cards appearing in 80%+ of builds
@ -27,9 +40,21 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled)
- Detailed progress logging for multi-build orchestration
- User guide: `docs/user_guides/batch_build_compare.md`
- **Web UI Component Library**: Standardized UI components for consistent design across all pages
- 5 component partial template files (buttons, modals, forms, cards, panels)
- ~900 lines of component CSS styles
- Interactive JavaScript utilities (components.js)
- Living component library page at `/docs/components`
- 1600+ lines developer documentation (component_catalog.md)
- **Custom UI Enhancements**:
- Darker gray styling for home page buttons
- Visual highlighting for selected theme chips in deck builder
### Changed
_None_
- Optimized Docker build process: Reduced build time from ~134s to ~6s
- Removed redundant card_files copy (already mounted as volume)
- Added volume mounts for templates and static files (hot reload support)
- Migrated 5 templates to new component system (home, 404, 500, setup, commanders)
### Removed
_None_
@ -38,7 +63,7 @@ _None_
_None_
### Performance
_None_
- Docker hot reload now works for CSS and template changes (no rebuild required)
### Deprecated
_None_

View file

@ -283,6 +283,7 @@ See `.env.example` for the full catalog. Common knobs:
| `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. |
| `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. |
| `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. |
| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). See [Image Caching](docs/IMAGE_CACHING.md). |
| `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. |
| `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. |

View file

@ -10,21 +10,42 @@ ENV PYTHONUNBUFFERED=1
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# Install system dependencies if needed
# Install system dependencies including Node.js
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
# Copy package files for Node.js dependencies
COPY package.json package-lock.json* ./
# Install Node.js dependencies
RUN npm install
# Copy Tailwind/TypeScript config files
COPY tailwind.config.js postcss.config.js tsconfig.json ./
# Copy requirements for Python dependencies (for better caching)
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
# Copy Python application code (includes templates needed for Tailwind)
COPY code/ ./code/
COPY mypy.ini .
# Tailwind source is already in code/web/static/tailwind.css from COPY code/
# TypeScript sources are in code/web/static/ts/ from COPY code/
# Force fresh CSS build by removing any copied styles.css
RUN rm -f ./code/web/static/styles.css
# Build CSS and TypeScript
RUN npm run build
# Copy default configs in two locations:
# 1) /app/config is the live path (may be overlaid by a volume)
# 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted
@ -36,7 +57,9 @@ RUN mkdir -p owned_cards
# Store in /.defaults/card_files so it persists after volume mount
RUN mkdir -p /.defaults/card_files
# Copy entire card_files directory (will include cache if present, empty if not)
COPY card_files/ /.defaults/card_files/
# COMMENTED OUT FOR LOCAL DEV: card_files is mounted as volume anyway
# Uncomment for production builds or CI/CD
# COPY card_files/ /.defaults/card_files/
# Create necessary directories as mount points
RUN mkdir -p deck_files logs csv_files card_files config /.defaults

View file

@ -309,6 +309,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. |
| `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. |
| `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). |
| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). Requires ~3-6 GB. See [Image Caching](docs/IMAGE_CACHING.md). |
| `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. |
| `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. |

View file

@ -3,24 +3,27 @@
## [Unreleased]
### Summary
Major new feature: Build X and Compare with Intelligent Synergy Builder. Run the same deck configuration multiple times to see variance, compare results side-by-side, and create optimized "best-of" decks.
Web UI improvements with Tailwind CSS migration, component library, and optional card image caching for faster performance.
### Added
- **Build X and Compare**: Build 1-10 decks in parallel with same configuration
- Side-by-side comparison with card overlap statistics
- Smart filtering of guaranteed cards
- Rebuild button for quick iterations
- ZIP export of all builds
- **Synergy Builder**: Create optimized deck from multiple builds
- Intelligent scoring (frequency + EDHREC + themes)
- Color-coded synergy preview
- Full metadata export (CSV/TXT/JSON)
- Partner commander support
- Feature flag: `ENABLE_BATCH_BUILD` (default: on)
- User guide: `docs/user_guides/batch_build_compare.md`
- **Card Image Caching**: Optional local image cache for faster card display
- Downloads card images from Scryfall bulk data
- Graceful fallback to Scryfall API for uncached images
- Enable with `CACHE_CARD_IMAGES=1` environment variable
- Intelligent statistics caching (weekly refresh, matching card data staleness)
- **Component Library**: Living documentation at `/docs/components`
- Interactive examples of all UI components
- Reusable Jinja2 macros for consistent design
- Component partial templates for reuse across pages
### Changed
_None_
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
- Tailwind CSS v3 with custom MTG color palette
- PostCSS build pipeline with autoprefixer
- Minimized inline styles in favor of shared CSS classes
- **Docker Build Optimization**: Improved developer experience
- Hot reload for templates and CSS (no rebuild needed)
- **Template Modernization**: Migrated templates to use component system
### Removed
_None_
@ -29,10 +32,14 @@ _None_
_None_
### Performance
_None_
- Hot reload for CSS/template changes (no Docker rebuild needed)
- Optional image caching reduces Scryfall API calls
- Faster page loads with optimized CSS
### For Users
_No changes yet_
- Faster card image loading with optional caching
- Cleaner, more consistent web UI design
- Improved page load performance
### Deprecated
_None_

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

@ -350,6 +350,44 @@ def initial_setup() -> None:
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:
"""Regenerate processed Parquet from existing raw file.

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')){

View file

@ -105,6 +105,9 @@ services:
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
# Card Image Caching (optional, uses Scryfall bulk data API)
CACHE_CARD_IMAGES: "1" # 1=download images to card_files/images/, 0=fetch from Scryfall API on demand
# Build Stage Ordering
WEB_STAGE_ORDER: "new" # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill
@ -250,5 +253,8 @@ services:
- ${PWD}/card_files:/app/card_files
- ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards
# Mount code for hot-reload during development (templates, static files)
- ${PWD}/code/web/templates:/app/code/web/templates
- ${PWD}/code/web/static:/app/code/web/static
working_dir: /app
restart: unless-stopped

View file

@ -107,6 +107,9 @@ services:
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
# Card Image Caching (optional, uses Scryfall bulk data API)
CACHE_CARD_IMAGES: "1" # 1=download images to card_files/images/, 0=fetch from Scryfall API on demand
# Build Stage Ordering
WEB_STAGE_ORDER: "new" # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill

52
docs/NODE_DEPENDENCIES.md Normal file
View file

@ -0,0 +1,52 @@
# Node.js Dependencies for Web UI Development
## Prerequisites
- Node.js 18+ (LTS recommended)
- npm 9+ (comes with Node.js)
## Installation
```bash
npm install
```
## Dependencies
### Tailwind CSS v3
- **tailwindcss**: Utility-first CSS framework
- **postcss**: CSS transformation tool
- **autoprefixer**: Adds vendor prefixes automatically
### TypeScript
- **typescript**: TypeScript compiler for type-safe JavaScript
## Build Commands
### CSS Build
```bash
npm run build:css # One-time build
npm run watch:css # Watch mode for development
```
### TypeScript Build
```bash
npm run build:ts # One-time build
npm run watch:ts # Watch mode for development
```
### Combined Build
```bash
npm run build # Build CSS and TypeScript
npm run watch # Watch both CSS and TypeScript
```
## Project Structure
- `code/web/static/tailwind.css` - Tailwind entry point (source)
- `code/web/static/styles.css` - Generated CSS (git-ignored)
- `code/web/static/ts/` - TypeScript source files
- `code/web/static/js/` - Compiled JavaScript (git-ignored)
## Configuration Files
- `tailwind.config.js` - Tailwind CSS configuration
- `postcss.config.js` - PostCSS configuration
- `tsconfig.json` - TypeScript compiler configuration
- `package.json` - npm scripts and dependencies

View file

@ -1,25 +0,0 @@
# Card Authoring Guide
This guide captures the conventions used by the deckbuilder when new cards are added to the CSV inputs. Always validate your edits by running the fast tagging tests or a local build before committing changes.
## Modal double-faced & transform cards
The tagging and reporting pipeline expects one row per face for any multi-faced card (modal double-faced, transform, split, or adventure). Use the checklist below when adding or updating these entries:
1. **Canonical name** — Keep the `name` column identical for every face (e.g., `Valakut Awakening // Valakut Stoneforge`). Individual faces should instead set `face_name` when available; the merger preserves front-face copy for downstream consumers.
2. **Layout & side** — Populate `layout` with the value emitted by Scryfall (`modal_dfc`, `transform`, `split`, `adventure`, etc.) and include a `side` column (`a`, `b`, …). The merger uses `side` ordering when reconstructing per-face metadata.
3. **Mana details** — Supply `mana_cost`, `mana_value`, and `produces_mana` for every face. The per-face land snapshot and deck summary badges rely on these fields to surface the “DFC land” chip and annotated mana production.
4. **Type line accuracy** — Ensure `type_line` includes `Land` for any land faces. The builder counts a card toward land totals when at least one face includes `Land`.
5. **Tags & roles** — Tag every face with the appropriate `themeTags`, `roleTags`, and `card_tags`. The merge stage unions these sets so the finished card retains all relevant metadata.
6. **Commander eligibility** — Only the primary (`side == 'a'`) face is considered for commander legality. If you add a new MDFC commander, double-check that the front face satisfies the Commander rules text; otherwise the record is filtered during catalog refresh.
7. **Cross-check exports** — After the card is added, run a local build and confirm the deck exports include the new `DFCNote` column entry for the card. The annotation summarizes each land face so offline reviewers see the same guidance as the web UI.
### Diagnostics snapshot (optional)
When validating a large batch of MDFCs, enable the snapshot helper to inspect the merged faces:
- Set `DFC_PER_FACE_SNAPSHOT=1` (and optionally `DFC_PER_FACE_SNAPSHOT_PATH`) before running the tagging pipeline.
- Disable parallel tagging (`WEB_TAG_PARALLEL=0`) while the snapshot is active; the helper only writes output during sequential runs.
- Once tagging completes, review `logs/dfc_per_face_snapshot.json` for the card you added to verify mana fields, `produces_mana`, and land detection flags.
Following these guidelines keeps the deck summary badges, exporter annotations, and diagnostics snapshots in sync for every new double-faced card.

View file

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

View file

@ -1,92 +0,0 @@
# Headless & CLI Guide
Leverage the shared deckbuilding engine from the command line, in headless mode, or within containers.
## Table of contents
- [Entry points](#entry-points)
- [Switching modes in Docker](#switching-modes-in-docker)
- [Headless JSON configs](#headless-json-configs)
- [Environment overrides](#environment-overrides)
- [CLI argument reference](#cli-argument-reference)
- [Include/exclude lists from the CLI](#includeexclude-lists-from-the-cli)
- [Practical examples](#practical-examples)
---
## Entry points
- Interactive menu: `python code/main.py`
- Headless runner: `python code/headless_runner.py --config config/deck.json`
- Both executables share the same builder core used by the Web UI.
## Switching modes in Docker
Override the container entrypoint to run the CLI or headless flows inside Docker Compose or plain `docker run`.
```powershell
# Compose example
docker compose run --rm -e APP_MODE=cli web
# Compose with headless automation
docker compose run --rm `
-e APP_MODE=cli `
-e DECK_MODE=headless `
-e DECK_CONFIG=/app/config/deck.json `
web
```
Set `APP_MODE=cli` to switch from the Web UI to the textual interface. Add `DECK_MODE=headless` to skip prompts and immediately run the configured deck.
## Headless JSON configs
- Drop JSON files into `config/` (e.g., `config/deck.json`).
- Headless mode auto-runs the lone JSON file; if multiple exist, the CLI lists them with summaries (commander + themes).
- Config fields cover commander, bracket, include/exclude lists, theme preferences, owned-mode toggles, and output naming.
- Partner mechanics are optional: set `"enable_partner_mechanics": true` and supply either `"secondary_commander"` or `"background"` for combined commander runs.
## Environment overrides
When running in containers or automation, environment variables can override JSON settings. Typical variables include:
- `DECK_COMMANDER`
- `DECK_PRIMARY_CHOICE`, `DECK_SECONDARY_CHOICE`, `DECK_TERTIARY_CHOICE`
- `DECK_BRACKET_LEVEL`
- `DECK_ADD_LANDS`, `DECK_LAND_COUNT`, `DECK_CREATURE_COUNT`, `DECK_RAMP_COUNT`
Precedence order: **CLI flags > environment variables > JSON config > defaults**.
## CLI argument reference
Run `python code/headless_runner.py --help` to see the current argument surface. Highlights:
- Type indicators make expectations explicit (e.g., `PATH`, `NAME`, `INT`).
- Theme selection accepts human-readable names: `--primary-tag "Airbending"` instead of numeric indexes.
- Bracket selection via `--bracket-level`.
- Ideal counts such as `--land-count`, `--ramp-count`, `--creature-count`, and more.
## Include/exclude lists from the CLI
You can specify comma- or semicolon-separated lists directly through the CLI:
```powershell
python code/headless_runner.py `
--commander "Jace, Vryn's Prodigy" `
--include-cards "Sol Ring;Jace, the Mind Sculptor" `
--exclude-cards "Chaos Orb;Shahrazad" `
--enforcement-mode strict
```
Semicolons allow card names containing commas. Enforcement modes mirror the Web UI (`off`, `warn`, `strict`).
## Practical examples
```powershell
# Build a Goblins list with tuned counts
python code/headless_runner.py `
--commander "Krenko, Mob Boss" `
--primary-tag "Goblin Kindred" `
--creature-count 35 `
--land-count 33 `
--ramp-count 12
# Fire a headless run via Docker using an alternate config folder
docker compose run --rm `
-e APP_MODE=cli `
-e DECK_MODE=headless `
-e DECK_CONFIG=/app/config/custom_decks `
web
```
The CLI prints a detailed summary at the end of each run, including enforcement results, resolved themes, and export paths. All artifacts land in the same `deck_files/` folder used by the Web UI.

View file

@ -1,274 +0,0 @@
# All Cards Consolidation - Migration Guide
## Overview
This guide covers the migration from individual card CSV files to the consolidated `all_cards.parquet` format introduced in v2.8.0. The new format provides:
- **87% smaller file size** (3.74 MB vs ~30 MB for CSVs)
- **2-5x faster queries** (single lookup ~1.3ms, filters <70ms)
- **Improved caching** with automatic reload on file changes
- **Unified query API** via `AllCardsLoader` and `CardQueryBuilder`
## Migration Timeline
### Phase 1: v2.8.0 (Current) - Soft Launch
- ✅ AllCardsLoader and CardQueryBuilder available
- ✅ Automatic aggregation after tagging
- ✅ Legacy adapter functions provided for backward compatibility
- ✅ Feature flag `USE_ALL_CARDS_FILE=1` (enabled by default)
- ✅ Deprecation warnings logged when using legacy functions
- **cards.csv still supported** (kept for compatibility)
- **commander_cards.csv replaced** by `commander_cards.parquet`
### Phase 2: v2.9.0 - Broader Adoption
- Update deck_builder modules to use AllCardsLoader directly
- Update web routes to use new query API
- Continue supporting legacy adapter for external code
- Increase test coverage for real-world usage patterns
### Phase 3: v3.0.0 - Primary Method
- New code must use AllCardsLoader (no new legacy adapter usage)
- Legacy adapter still works but discouraged
- Documentation emphasizes new API
- cards.csv continues to work (not deprecated yet)
### Phase 4: v3.1.0+ - Sunset Legacy (Future)
- Remove legacy adapter functions
- Remove individual card CSV file support (cards.csv sunset)
- **commander_cards.parquet permanently replaces CSV version**
- All code uses AllCardsLoader exclusively
## Quick Start
### For New Code (Recommended)
```python
from code.services.all_cards_loader import AllCardsLoader
from code.services.card_query_builder import CardQueryBuilder
# Simple loading
loader = AllCardsLoader()
all_cards = loader.load()
# Single card lookup
sol_ring = loader.get_by_name("Sol Ring")
# Batch lookup
cards = loader.get_by_names(["Sol Ring", "Lightning Bolt", "Counterspell"])
# Filtering
red_cards = loader.filter_by_color_identity(["R"])
token_cards = loader.filter_by_themes(["tokens"], mode="any")
creatures = loader.filter_by_type("Creature")
# Text search
results = loader.search("create token", limit=100)
# Complex queries with fluent API
results = (CardQueryBuilder()
.colors(["G"])
.themes(["ramp"], mode="any")
.types("Creature")
.limit(20)
.execute())
```
### For Existing Code (Legacy Adapter)
If you have existing code using old file-loading patterns, the legacy adapter provides backward compatibility:
```python
# Old code continues to work (with deprecation warnings)
from code.services.legacy_loader_adapter import (
load_all_cards,
load_cards_by_name,
load_cards_by_type,
load_cards_with_tag,
)
# These still work but log deprecation warnings
all_cards = load_all_cards()
sol_ring = load_cards_by_name("Sol Ring")
creatures = load_cards_by_type("Creature")
token_cards = load_cards_with_tag("tokens")
```
**Important**: Migrate to the new API as soon as possible. Legacy functions will be removed in v3.1+.
## Migration Steps
### Step 1: Update Imports
**Before:**
```python
# Old pattern (if you were loading cards directly)
import pandas as pd
df = pd.read_csv("csv_files/some_card.csv")
```
**After:**
```python
from code.services.all_cards_loader import AllCardsLoader
loader = AllCardsLoader()
card = loader.get_by_name("Card Name")
```
### Step 2: Update Query Patterns
**Before:**
```python
# Old: Manual filtering
all_cards = load_all_individual_csvs() # Slow
creatures = all_cards[all_cards["type"].str.contains("Creature")]
red_creatures = creatures[creatures["colorIdentity"] == "R"]
```
**After:**
```python
# New: Efficient queries
loader = AllCardsLoader()
red_creatures = (CardQueryBuilder(loader)
.colors(["R"])
.types("Creature")
.execute())
```
### Step 3: Update Caching
**Before:**
```python
# Old: Manual caching
_cache = {}
def get_card(name):
if name not in _cache:
_cache[name] = load_from_csv(name)
return _cache[name]
```
**After:**
```python
# New: Built-in caching
loader = AllCardsLoader() # Caches automatically
card = loader.get_by_name(name) # Fast on repeat calls
```
## Feature Flag
The `USE_ALL_CARDS_FILE` environment variable controls whether the consolidated Parquet file is used:
```bash
# Enable (default)
USE_ALL_CARDS_FILE=1
# Disable (fallback to old method)
USE_ALL_CARDS_FILE=0
```
**When to disable:**
- Troubleshooting issues with the new loader
- Testing backward compatibility
- Temporary fallback during migration
## Performance Comparison
| Operation | Old (CSV) | New (Parquet) | Improvement |
|-----------|-----------|---------------|-------------|
| Initial load | ~2-3s | 0.104s | 20-30x faster |
| Single lookup | ~50-100ms | 1.3ms | 40-75x faster |
| Color filter | ~200ms | 2.1ms | 95x faster |
| Theme filter | ~500ms | 67ms | 7.5x faster |
| File size | ~30 MB | 3.74 MB | 87% smaller |
## Troubleshooting
### "all_cards.parquet not found"
Run the aggregation process:
1. Web UI: Go to Setup page → "Rebuild Card Files" button
2. CLI: `python code/scripts/aggregate_cards.py`
3. Automatic: Run tagging workflow (aggregation happens automatically)
### Deprecation Warnings
```
DEPRECATION: load_cards_by_name() called. Migrate to AllCardsLoader().get_by_name() before v3.1+
```
**Solution**: Update your code to use the new API as shown in this guide.
### Performance Issues
```python
# Check cache status
loader = AllCardsLoader()
stats = loader.get_stats()
print(stats) # Shows cache age, file size, etc.
# Force reload if data seems stale
loader.load(force_reload=True)
# Clear cache
loader.clear_cache()
```
### Feature Flag Not Working
Ensure environment variable is set before importing:
```python
import os
os.environ['USE_ALL_CARDS_FILE'] = '1'
# Then import
from code.services.all_cards_loader import AllCardsLoader
```
## Testing Your Migration
```python
# Run migration compatibility tests
pytest code/tests/test_migration_compatibility.py -v
# Run all cards loader tests
pytest code/tests/test_all_cards_loader.py -v
```
## FAQ
**Q: Do I need to regenerate all_cards.parquet after tagging?**
A: No, it's automatic. Aggregation runs after tagging completes. You can manually trigger via "Rebuild Card Files" button if needed.
**Q: What happens to cards.csv?**
A: Still supported through v3.0.x for compatibility. Will be sunset in v3.1+. Start migrating now.
**Q: What about commander_cards.csv?**
A: Already replaced by `commander_cards.parquet` in v2.8.0. CSV version is no longer used.
**Q: Can I use both methods during migration?**
A: Yes, the legacy adapter allows mixed usage, but aim to fully migrate to the new API.
**Q: Will my existing decks break?**
A: No, existing decks are unaffected. This only changes how cards are loaded internally.
**Q: How do I disable the new loader?**
A: Set `USE_ALL_CARDS_FILE=0` environment variable. Not recommended except for troubleshooting.
**Q: Are there any breaking changes?**
A: No breaking changes in v2.8.0. Legacy functions work with deprecation warnings. Breaking changes planned for v3.1+.
## Support
If you encounter issues during migration:
1. Check deprecation warnings in logs
2. Run migration compatibility tests
3. Try disabling feature flag temporarily
4. File an issue on GitHub with details
## Summary
**Use AllCardsLoader** for all new code
**Migrate existing code** using this guide
**Test thoroughly** with provided test suites
**Monitor deprecation warnings** and address them
**Plan ahead** for v3.1+ sunset of legacy functions
The new consolidated format provides significant performance improvements and a cleaner API. Start migrating today!

View file

@ -1,63 +0,0 @@
# MDFC Staging QA Checklist
Use this checklist when validating the MDFC merge in staging. The merge now runs unconditionally; set `DFC_COMPAT_SNAPSHOT=1` when you also need the legacy unmerged snapshots for downstream validation.
_Last updated: 2025-10-02_
## Prerequisites
- Staging environment (Docker Compose or infrastructure equivalent) can override environment variables for the web service.
- Latest code synced with the MDFC merge helper (`code/scripts/refresh_commander_catalog.py`).
- Virtualenv or container image contains current project dependencies (`pip install -r requirements.txt`).
## Configuration Steps
1. Set the staging web service environment as needed:
- `DFC_COMPAT_SNAPSHOT=1` when downstream teams still require the compatibility snapshot.
- Optional diagnostics helpers: `SHOW_DIAGNOSTICS=1`, `SHOW_LOGS=1` (helps confirm telemetry output during smoke testing).
2. Inside the staging container (or server), regenerate commander data:
```powershell
python -m code.scripts.refresh_commander_catalog
```
- Verify the script reports both the merged output (`csv_files/commander_cards.csv`) and the compatibility snapshot (`csv_files/compat_faces/commander_cards_unmerged.csv`).
3. Restart the web service so the refreshed files (and optional compatibility snapshot setting) take effect.
## Smoke QA
| Area | Steps | Pass Criteria |
| --- | --- | --- |
| Commander Browser | Load `/commanders`, search for a known MDFC commander (e.g., "Elmar, Ulvenwald Informant"), flip faces, paginate results. | No duplicate rows per face, flip control works, pagination remains responsive. |
| Deck Builder | Run a New Deck build with a commander that adds MDFC lands (e.g., "Atraxa, Grand Unifier" with MDFC swap option). | Deck summary shows "Lands: X (Y with DFC)" copy, MDFC notes render, CLI summary matches web copy (check download/export). |
| Commander Exclusions | Attempt to search for a commander that should be excluded because only the back face is legal (e.g., "Withengar Unbound"). | UI surfaces exclusion guidance; the commander is not selectable. |
| Diagnostics | Open `/diagnostics` with `SHOW_DIAGNOSTICS=1`. Confirm MDFC telemetry panel shows merged counts. | `dfc_merge_summary` card present with non-zero merged totals; land telemetry includes MDFC contribution counts. |
| Logs | Tail application logs via `/logs` or container logs during a build. | No errors related to tag merging or commander loading. |
## Automated Checks
Run the targeted test suite to ensure MDFC regressions are caught:
```powershell
c:/Users/Matt/mtg_python/mtg_python_deckbuilder/.venv/Scripts/python.exe -m pytest -q ^
code/tests/test_land_summary_totals.py ^
code/tests/test_commander_primary_face_filter.py ^
code/tests/test_commander_exclusion_warnings.py
```
- All tests should pass. Investigate any failures before promoting the flag.
## Downstream Sign-off
1. Provide consumers with:
- Merged file: `csv_files/commander_cards.csv`
- Compatibility snapshot: `csv_files/compat_faces/commander_cards_unmerged.csv`
2. Share expected merge metrics (`logs/dfc_merge_summary.json`) to help validate MDFC counts.
3. Collect acknowledgements that downstream pipelines work with the merged file (or have cut over) before retiring the compatibility flag.
## Rollback Plan
- Disable `DFC_COMPAT_SNAPSHOT` (or leave it unset) and rerun `python -m code.scripts.refresh_commander_catalog` if compatibility snapshots are no longer required.
- Revert to the previous committed commander CSV if needed (`git checkout -- csv_files/commander_cards.csv`).
- Document the issue in the roadmap and schedule the fix before reattempting the staging rollout.
## Latest Run (2025-10-02)
- Environment: staging compose updated (temporarily set `ENABLE_DFC_MERGE=compat`, now retired) and reconfigured with optional `DFC_COMPAT_SNAPSHOT=1` for compatibility checks.
- Scripts executed:
- `python -m code.scripts.refresh_commander_catalog --compat-snapshot`
- `python -m code.scripts.preview_dfc_catalog_diff --compat-snapshot --output logs/dfc_catalog_diff.json`
- Automated tests passed:
- `code/tests/test_land_summary_totals.py`
- `code/tests/test_commander_primary_face_filter.py`
- `code/tests/test_commander_exclusion_warnings.py`
- Downstream sign-off: `logs/dfc_catalog_diff.json` shared with catalog consumers alongside `csv_files/compat_faces/commander_cards_unmerged.csv`; acknowledgements recorded in `docs/releases/dfc_merge_rollout.md`.

View file

@ -1,59 +0,0 @@
# Random Mode Theme Exclusions
The curated random theme pool keeps auto-fill suggestions focused on themes that lead to actionable Commander builds. This document summarizes the heuristics and manual exclusions that shape the pool and explains how to discover every theme when you want to override the curated list.
## Heuristics applied automatically
We remove a theme token from the curated pool when any of the following conditions apply:
1. **Insufficient examples** fewer than five unique commanders in the catalog advertise the token.
2. **Kindred and species-specific labels** anything matching keywords such as `kindred`, `tribal`, `clan`, or endings like `" tribe"` is treated as commander-specific and filtered out.
3. **Global catch-alls** broad phrases (for example `goodstuff`, `legendary matter`, `historic matter`) offer little guidance for theme selection, so they are excluded.
4. **Over-represented themes** if 30% or more of the commander catalog advertises a token, it is removed from the surprise pool to keep suggestions varied.
These rules are codified in `code/deck_builder/random_entrypoint.py` and surfaced via the diagnostics panel and the reporting script.
## Manual exclusions
Some descriptors are technically valid tokens but still degrade the surprise experience. They live in `config/random_theme_exclusions.yml` so we can document why they are hidden and keep the list reviewable.
| Category | Why it is excluded | Tokens |
| --- | --- | --- |
| `ubiquitous_baseline` | Baseline game actions every deck performs; surfacing them would be redundant. | `card advantage`, `card draw`, `removal`, `interaction` |
| `degenerate_catchall` | Generic "good stuff" style descriptors that do not communicate a coherent plan. | `value`, `good stuff`, `goodstuff`, `good-stuff`, `midrange value` |
| `non_theme_qualifiers` | Power-level or budget qualifiers; these belong in settings, not theme suggestions. | `budget`, `competitive`, `cedh`, `high power` |
Themes removed here still resolve just fine when you type them manually into any theme field or when you import them from permalinks, sessions, or the CLI.
### Keeping the list discoverable
The reporting script can export the manual list alongside the curated pool:
```powershell
# Markdown summary with exclusions
python code/scripts/report_random_theme_pool.py --format markdown
# Structured exclusions for tooling
python code/scripts/report_random_theme_pool.py --write-exclusions logs/random_theme_exclusions.json
```
Both commands refresh the commander catalog on demand and mirror the exact heuristics used by the web UI and API.
## Surfacing the information in the app
When diagnostics are enabled (`SHOW_DIAGNOSTICS=1`), the `/diagnostics` panel shows:
- Total curated pool size and coverage.
- Counts per exclusion reason (including manual categories).
- Sample tokens and the manual categories that removed them.
- Tag index telemetry (build count, cache hit rate) for performance monitoring.
This makes it easy to audit the pool after catalog or heuristic changes.
## Updating the manual list
1. Edit `config/random_theme_exclusions.yml` and add or adjust entries (keep tokens lowercase; normalization happens automatically).
2. Run `python code/scripts/report_random_theme_pool.py --format markdown --refresh` to verify the pool summary.
3. Commit the YAML update together with the regenerated documentation when you are satisfied.
The curated pool will pick up the change automatically thanks to the file timestamp watcher in `random_entrypoint.py`.

View file

@ -1,31 +0,0 @@
# MDFC Merge Rollout (2025-10-02)
## Summary
- Staging environment refreshed with the MDFC merge permanently enabled; compatibility snapshot retained via `DFC_COMPAT_SNAPSHOT=1` during validation.
- Commander catalog rebuilt with `python -m code.scripts.refresh_commander_catalog --compat-snapshot`, generating both the merged output and `csv_files/compat_faces/commander_cards_unmerged.csv` for downstream comparison.
- Diff artifact `logs/dfc_catalog_diff.json` captured via `python -m code.scripts.preview_dfc_catalog_diff --compat-snapshot --output logs/dfc_catalog_diff.json` and shared with downstream consumers.
- `ENABLE_DFC_MERGE` guard removed across the codebase; documentation updated to reflect the always-on merge and optional compatibility snapshot workflow.
## QA Artifacts
| Artifact | Description |
| --- | --- |
| `docs/qa/mdfc_staging_checklist.md` | Latest run log documents the staging enablement procedure and verification steps. |
| `logs/dfc_catalog_diff.json` | JSON diff summarising merged vs. unmerged commander/catalog rows for parity review. |
| `csv_files/commander_cards.csv` | Merged commander catalog generated after guard removal. |
| `csv_files/compat_faces/commander_cards_unmerged.csv` | Legacy snapshot retained for downstream validation during the final review window. |
## Automated Verification
| Check | Command | Result |
| --- | --- | --- |
| MDFC land accounting | `python -m pytest -q code/tests/test_land_summary_totals.py` | ✅ Passed |
| Commander primary-face filter | `python -m pytest -q code/tests/test_commander_primary_face_filter.py` | ✅ Passed |
| Commander exclusion warnings | `python -m pytest -q code/tests/test_commander_exclusion_warnings.py` | ✅ Passed |
## Downstream Sign-off
| Consumer / Surface | Validation | Status |
| --- | --- | --- |
| Web UI (builder + diagnostics) | MDFC staging checklist smoke QA | ✅ Complete |
| CLI / Headless workflows | Targeted pytest suite confirmations (see above) | ✅ Complete |
| Data exports & analytics | `logs/dfc_catalog_diff.json` review against `commander_cards_unmerged.csv` | ✅ Complete |
All downstream teams confirmed parity with the merged catalog and agreed to proceed without the `ENABLE_DFC_MERGE` guard. Compatibility snapshots remain available via `DFC_COMPAT_SNAPSHOT=1` for any follow-up spot checks.

View file

@ -1,212 +0,0 @@
# MTG Deckbuilder Web UI Style Guide
## Design Tokens
Design tokens provide a consistent foundation for all UI elements. These are defined as CSS custom properties in `code/web/static/styles.css`.
### Spacing Scale
Use the spacing scale for margins, padding, and gaps:
```css
--space-xs: 0.25rem; /* 4px - Tight spacing within components */
--space-sm: 0.5rem; /* 8px - Default gaps between small elements */
--space-md: 0.75rem; /* 12px - Standard component padding */
--space-lg: 1rem; /* 16px - Section spacing, card gaps */
--space-xl: 1.5rem; /* 24px - Major section breaks */
--space-2xl: 2rem; /* 32px - Page-level spacing */
```
**Usage examples:**
- Chip gaps: `gap: var(--space-sm)`
- Panel padding: `padding: var(--space-md)`
- Section margins: `margin: var(--space-xl) 0`
### Typography Scale
Consistent font sizes for hierarchy:
```css
--text-xs: 0.75rem; /* 12px - Meta info, badges */
--text-sm: 0.875rem; /* 14px - Secondary text */
--text-base: 1rem; /* 16px - Body text */
--text-lg: 1.125rem; /* 18px - Subheadings */
--text-xl: 1.25rem; /* 20px - Section headers */
--text-2xl: 1.5rem; /* 24px - Page titles */
```
**Font weights:**
```css
--font-normal: 400; /* Body text */
--font-medium: 500; /* Emphasis */
--font-semibold: 600; /* Headings */
--font-bold: 700; /* Strong emphasis */
```
### Border Radius
Consistent corner rounding:
```css
--radius-sm: 4px; /* Subtle rounding */
--radius-md: 6px; /* Buttons, inputs */
--radius-lg: 8px; /* Panels, cards */
--radius-xl: 12px; /* Large containers */
--radius-full: 999px; /* Pills, chips */
```
### Color Tokens
#### Semantic Colors
```css
--bg: #0f0f10; /* Page background */
--panel: #1a1b1e; /* Panel/card backgrounds */
--text: #e8e8e8; /* Primary text */
--muted: #b6b8bd; /* Secondary text */
--border: #2a2b2f; /* Borders and dividers */
--ring: #60a5fa; /* Focus indicator */
--ok: #16a34a; /* Success states */
--warn: #f59e0b; /* Warning states */
--err: #ef4444; /* Error states */
```
#### MTG Color Identity
```css
--green-main: rgb(0,115,62);
--green-light: rgb(196,211,202);
--blue-main: rgb(14,104,171);
--blue-light: rgb(179,206,234);
--red-main: rgb(211,32,42);
--red-light: rgb(235,159,130);
--white-main: rgb(249,250,244);
--white-light: rgb(248,231,185);
--black-main: rgb(21,11,0);
--black-light: rgb(166,159,157);
```
## Component Patterns
### Chips
Chips display tags, status indicators, and metadata.
**Basic chip:**
```html
<span class="chip">
<span class="dot" style="background: var(--ok);"></span>
Label
</span>
```
**Chip containers:**
```html
<!-- Flexbox inline chips (existing) -->
<div class="chips-inline">
<span class="chip">Tag 1</span>
<span class="chip">Tag 2</span>
</div>
<!-- Grid auto-fit chips (new - responsive) -->
<div class="chips-grid">
<span class="chip">Item 1</span>
<span class="chip">Item 2</span>
<span class="chip">Item 3</span>
</div>
<!-- Small grid (90px min) -->
<div class="chips-grid chips-grid-sm">...</div>
<!-- Large grid (160px min) -->
<div class="chips-grid chips-grid-lg">...</div>
```
### Summary Panels
Responsive grid panels for dashboard-style layouts:
```html
<div class="summary-panels">
<div class="summary-panel">
<div class="summary-panel-header">Panel Title</div>
<div class="summary-panel-content">
Panel content here
</div>
</div>
<div class="summary-panel">
<div class="summary-panel-header">Another Panel</div>
<div class="summary-panel-content">
More content
</div>
</div>
</div>
```
Panels automatically flow into columns based on available width (240px min per column).
## Responsive Breakpoints
The UI uses CSS Grid `auto-fit` patterns that adapt naturally to viewport width:
- **Mobile** (< 640px): Single column layouts
- **Tablet** (640px - 900px): 2-column where space allows
- **Desktop** (> 900px): Multi-column with `auto-fit`
Grid patterns automatically adjust without media queries:
```css
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
```
## Accessibility
### Focus Indicators
All interactive elements receive a visible focus ring:
```css
.focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
```
### Color Contrast
- Text on backgrounds: Minimum 4.5:1 ratio (WCAG AA)
- Large text/headings: Minimum 3:1 ratio
- Interactive elements: Sufficient contrast for all states
### Keyboard Navigation
- Tab order follows visual flow
- Skip links available for main content areas
- All controls accessible via keyboard
## Theme Support
The app supports multiple themes via `data-theme` attribute:
- `dark` (default): Dark mode optimized
- `light-blend`: Light mode with warm tones
- `high-contrast`: Maximum contrast for visibility
- `cb-friendly`: Color-blind friendly palette
Themes automatically adjust all token values.
## Best Practices
1. **Use tokens over hardcoded values**
- ✅ `padding: var(--space-md)`
- ❌ `padding: 12px`
2. **Leverage auto-fit grids for responsive layouts**
- ✅ `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr))`
- ❌ Multiple media queries with fixed columns
3. **Maintain semantic color usage**
- Use `--ok`, `--warn`, `--err` for states
- Use MTG colors for identity-specific UI
- Use `--text`, `--muted` for typography hierarchy
4. **Keep components DRY**
- Reuse `.chip`, `.summary-panel`, `.chips-grid` patterns
- Extend with modifiers, not duplicates
5. **Test across viewports**
- Verify auto-fit breakpoints work smoothly
- Check mobile (375px), tablet (768px), desktop (1440px)

View file

@ -1,162 +0,0 @@
# Theme Catalog Advanced Guide
Additional details for developers and power users working with the theme catalog, editorial tooling, and diagnostics.
## Table of contents
- [theme_catalog.csv schema](#theme_catalogcsv-schema)
- [HTMX API endpoints](#htmx-api-endpoints)
- [Caching, diagnostics, and metrics](#caching-diagnostics-and-metrics)
- [Governance principles](#governance-principles)
- [Operational tooling](#operational-tooling)
- [Refreshing catalogs](#refreshing-catalogs)
- [Snapshotting taxonomy](#snapshotting-taxonomy)
- [Adaptive splash penalty experiments](#adaptive-splash-penalty-experiments)
- [Editorial pipeline](#editorial-pipeline)
- [Script summary](#script-summary)
- [Example configuration](#example-configuration)
- [Duplicate suppression controls](#duplicate-suppression-controls)
- [Coverage metrics and KPIs](#coverage-metrics-and-kpis)
- [Description mapping overrides](#description-mapping-overrides)
- [Validation and schema tooling](#validation-and-schema-tooling)
## theme_catalog.csv schema
`theme_catalog.csv` is the normalized artifact consumed by headless builds, supplemental themes, and diagnostics panels. The file starts with a header comment in the format `# theme_catalog version=<hash>` followed by a standard CSV header with these columns:
| Column | Description |
| --- | --- |
| `theme` | Normalized display label used across the app and JSON exports. |
| `commander_count` | Number of commanders tagged with the theme in `commander_cards.csv`. |
| `card_count` | Number of non-commander cards carrying the theme tag across primary CSVs. |
| `source_count` | Combined count (`commander_count + card_count`) to simplify weighting heuristics. |
| `last_generated_at` | ISO-8601 timestamp captured at generation time (UTC). Useful for verifying stale catalogs in diagnostics. |
| `version` | Deterministic SHA-256 prefix derived from the ordered theme list; this value flows into exports as `themeCatalogVersion` and `/status/theme_metrics`. |
Consumers should treat additional columns as experimental. If you add new fields, update this table and the supplemental theme tests that assert schema coverage.
## HTMX API endpoints
The upcoming theme picker UI is powered by two FastAPI endpoints.
### `GET /themes/api/themes`
Parameters:
- `q`: substring search across theme names and synergies.
- `archetype`: filter by `deck_archetype`.
- `bucket`: popularity bucket (Very Common, Common, Uncommon, Niche, Rare).
- `colors`: comma-separated color initials (e.g. `G,W`).
- `limit` / `offset`: pagination (limit defaults to 50, max 200).
- `diagnostics=1`: surfaces `has_fallback_description` and `editorial_quality` (requires `WEB_THEME_PICKER_DIAGNOSTICS=1`).
The response includes `count`, the filtered `items`, and `next_offset` for subsequent requests. Diagnostic mode adds extra telemetry fields.
### `GET /themes/api/theme/{id}`
Parameters:
- `uncapped=1`: (diagnostics) returns `uncapped_synergies`, combining curated, enforced, and inferred sets.
- `diagnostics=1`: exposes editorial metadata such as `editorial_quality` and `has_fallback_description`.
The payload merges curated data with editorial artifacts (`example_cards`, `example_commanders`, etc.) and respects the same diagnostic feature flag.
## Caching, diagnostics, and metrics
- Responses include an `ETag` header derived from catalog metadata so consumers can perform conditional GETs.
- `/themes/status` reports freshness and stale indicators; `/themes/refresh` (POST) triggers a background rebuild.
- When `WEB_THEME_PICKER_DIAGNOSTICS=1` is set, the app records:
- Filter cache hits/misses and duration (`X-ThemeCatalog-Filter-Duration-ms`).
- Preview cache metrics (`/themes/metrics` exposes counts, hit rates, TTL, and average build time).
- Skeleton loaders ship with the HTMX fragments to keep perceived latency low.
## Governance principles
To keep the catalog healthy, the project follows a lightweight governance checklist:
1. **Minimum examples** target at least two example cards and one commander per established theme.
2. **Deterministic preview assembly** curated examples first, then role-based samples (payoff/enabler/support/wildcard), then placeholders if needed.
3. **Splash relax policy** four- and five-color commanders may include a single off-color enabler with a small penalty, preventing over-pruning.
4. **Popularity buckets are advisory** they guide filters and UI hints but never directly influence scoring.
5. **Taxonomy expansion bar** new high-level archetypes require a distinct pattern, at least eight representative cards, and no overlap with existing themes.
6. **Editorial quality tiers** optional `editorial_quality: draft|reviewed|final` helps prioritize review passes.
7. **Deterministic sampling** seeds derive from `theme|commander` hashes; scoring code should emit `reasons[]` to explain decisions and remain regression-test friendly.
See `docs/theme_taxonomy_rationale.md` for the underlying rationale and roadmap.
## Operational tooling
### Refreshing catalogs
- Primary builder: `python code/scripts/build_theme_catalog.py`
- Options:
- `--limit N`: preview a subset without overwriting canonical outputs (unless `--allow-limit-write`).
- `--output path`: write to an alternate path; suppresses YAML backfill to avoid mutating tracked files.
- `--backfill-yaml` or `EDITORIAL_BACKFILL_YAML=1`: fill missing descriptions and popularity buckets in YAML files.
- `--force-backfill-yaml`: overwrite existing description/popularity fields.
- `EDITORIAL_SEED=<int>`: force a deterministic ordering when heuristics use randomness.
- `EDITORIAL_AGGRESSIVE_FILL=1`: pad sparse themes with inferred synergies.
- `EDITORIAL_POP_BOUNDARIES="a,b,c,d"`: tune popularity thresholds.
- `EDITORIAL_POP_EXPORT=1`: emit `theme_popularity_metrics.json` summaries.
### Snapshotting taxonomy
`python -m code.scripts.snapshot_taxonomy` writes `logs/taxonomy_snapshots/taxonomy_<timestamp>.json` with a SHA-256 hash. Identical content is skipped unless you supply `--force`. Use snapshots before experimenting with taxonomy-aware sampling.
### Adaptive splash penalty experiments
Set `SPLASH_ADAPTIVE=1` to scale off-color enabler penalties based on commander color count. Tune with `SPLASH_ADAPTIVE_SCALE` (e.g. `1:1.0,2:1.0,3:1.0,4:0.6,5:0.35`). Analytics aggregate both static and adaptive reasons for comparison.
## Editorial pipeline
### Script summary
- `code/scripts/generate_theme_editorial_suggestions.py`
- Proposes `example_cards`, `example_commanders`, and `synergy_commanders` using card CSVs and tagging heuristics.
- `--augment-synergies` can pad sparse `synergies` arrays prior to suggestion.
- `--apply` writes results; dry runs print suggestions for review.
- `code/scripts/lint_theme_editorial.py`
- Validates annotation formats, min/max counts, and deduplication. Combine with environment toggles (`EDITORIAL_REQUIRE_DESCRIPTION`, `EDITORIAL_REQUIRE_POPULARITY`) for stricter gating.
### Example configuration
```powershell
# Dry run on the first 25 themes
python code/scripts/generate_theme_editorial_suggestions.py
# Apply across the catalog with augmentation and min example commanders set to 5
python code/scripts/generate_theme_editorial_suggestions.py --apply --augment-synergies --min-examples 5
# Lint results
python code/scripts/lint_theme_editorial.py
```
Editorial output depends on current CSV data. Expect ordering or composition changes after upstream dataset refreshes—treat full-catalog regeneration as an operational task and review diffs carefully.
### Duplicate suppression controls
`code/scripts/synergy_promote_fill.py` can rebalance example cards:
```powershell
python code/scripts/synergy_promote_fill.py --fill-example-cards --common-card-threshold 0.18 --print-dup-metrics
```
- `--common-card-threshold`: filters cards appearing in more than the specified fraction of themes (default `0.18`).
- Use metrics output to tune thresholds so staple utility cards stay in check without removing legitimate thematic cards.
### Coverage metrics and KPIs
- `EDITORIAL_INCLUDE_FALLBACK_SUMMARY=1` embeds a `description_fallback_summary` block in the generated catalog (`generic_total`, `generic_plain`, `generic_pct`, etc.).
- Regression tests use these metrics to ratchet down generic descriptions over time.
- Historical trends are appended to `config/themes/description_fallback_history.jsonl` for analysis.
### Description mapping overrides
Customize automatic descriptions without editing code:
- Add `config/themes/description_mapping.yml` with entries:
```yaml
- triggers: ["sacrifice", "aristocrat"]
description: "Leans on sacrifice loops and {SYNERGIES}."
```
- The first matching trigger wins (case-insensitive substring search).
- `{SYNERGIES}` expands to a short clause listing the top synergies when available, and disappears gracefully if not.
- Internal defaults remain as fallbacks when the mapping file is absent.
## Validation and schema tooling
Run validators to maintain catalog quality:
```powershell
python code/scripts/validate_theme_catalog.py
python code/scripts/validate_theme_catalog.py --rebuild-pass
python code/scripts/validate_theme_catalog.py --schema
python code/scripts/validate_theme_catalog.py --yaml-schema
python code/scripts/validate_theme_catalog.py --strict-alias
```
Per-theme YAML files (under `config/themes/catalog/`) are tracked in source control. Keys such as `metadata_info` replace the legacy `provenance`; the validator treats missing migrations as warnings until the deprecation completes.

View file

@ -1,65 +0,0 @@
# Theme Taxonomy Rationale & Governance
This document captures decision criteria and rationale for expanding, merging, or refining the theme taxonomy.
## Goals
- Maintain meaningful, player-recognizable buckets.
- Avoid overspecialization (micro-themes) that dilute search & filtering.
- Preserve sampling diversity and editorial sustainability.
## Expansion Checklist
A proposed new theme SHOULD satisfy ALL of:
1. Distinct Strategic Identity: The game plan (win condition / resource axis) is not already adequately described by an existing theme or combination of two existing themes.
2. Representative Card Depth: At least 8 broadly played, format-relevant cards (EDHREC / common play knowledge) naturally cluster under this identity.
3. Commander Support: At least 3 reasonable commander candidates (not including fringe silver-bullets) benefit from or enable the theme.
4. Non-Subset Test: The candidate is not a strict subset of an existing theme's synergy list (check overlap ≥70% == probable subset).
5. Editorial Coverage Plan: Concrete initial examples & synergy tags identified; no reliance on placeholders at introduction.
If any criterion fails -> treat as a synergy tag inside an existing theme rather than a standalone theme.
## Candidate Themes & Notes
| Candidate | Rationale | Risks / Watchouts | Initial Verdict |
|-----------|-----------|-------------------|-----------------|
| Combo | High-synergy deterministic or infinite loops. Already partly surfaced via combo detection features. | Over-broad; could absorb unrelated value engines. | Defer; emphasize combo detection tooling instead. |
| Storm | Spell-chain count scaling (Grapeshot, Tendrils). Distinct engine requiring density/rituals. | Low breadth in casual metas; may overlap with Spellslinger. | Accept (pending 8-card list + commander examples). |
| Extra Turns | Time Walk recursion cluster. | Potential negative play perception; governance needed to avoid glorifying NPE lines. | Tentative accept (tag only until list curated). |
| Group Hug / Politics | Resource gifting & table manipulation. | Hard to score objectively; card set is broad. | Accept with curated examples to anchor definition. |
| Pillowfort | Defensive taxation / attack deterrence (Ghostly Prison line). | Overlap with Control / Enchantments. | Accept; ensure non-redundant with generic Enchantments. |
| Toolbox / Tutors | Broad search utility enabling silver-bullet packages. | Tutors already subject to bracket policy thresholds; broad risk. | Defer; retain as synergy tag only. |
| Treasure Matters | Explicit treasure scaling (Academy Manufactor, Prosper). | Rapidly evolving; needs periodic review. | Accept. |
| Monarch / Initiative | Alternate advantage engines via emblems/dungeons. | Initiative narrower post-rotation; watch meta shifts. | Accept (merge both into a single theme for now). |
## Merge / Normalization Guidelines
When overlap (Jaccard) between Theme A and Theme B > 0.55 across curated+enforced synergies OR example card intersection ≥60%, evaluate for merge. Preference order:
1. Retain broader, clearer name.
2. Preserve curated examples; move excess to synergy tags.
3. Add legacy name to `aliases` for backward compatibility.
## Example Count Enforcement
Threshold flips to hard enforcement after global coverage >90%:
- Missing required examples -> linter error (`lint_theme_editorial.py --require-examples`).
- Build fails CI unless waived with explicit override label.
## Splash Relax Policy Rationale
- Prevents 45 color commanders from feeling artificially constrained when one enabling piece lies just outside colors.
- Controlled by single-card allowance + -0.3 score penalty so off-color never outranks true color-aligned payoffs.
## Popularity Buckets Non-Scoring Principle
Popularity reflects observational frequency and is intentionally orthogonal to sampling to avoid feedback loops. Any future proposal to weight by popularity must include a diversity impact analysis and opt-in feature flag.
## Determinism & Reproducibility
All sampling randomness is derived from `seed = hash(theme|commander)`; taxonomy updates must document any score function changes in `CHANGELOG.md` and provide transition notes if output ordering shifts beyond acceptable tolerance.
## Governance Change Process
1. Open a PR modifying taxonomy YAML or this file.
2. Include: rationale, representative card list, commander list, overlap analysis with nearest themes.
3. Run catalog build + linter; attach metrics snapshot (`preview_metrics_snapshot.py`).
4. Reviewer checks duplication, size, overlap, enforcement thresholds.
## Future Considerations
- Automated overlap dashboard (heatmap) for candidate merges.
- Nightly diff bot summarizing coverage & generic description regression.
- Multi-dimensional rarity quota experimentation (moved to Deferred section for now).
---
Last updated: 2025-09-20

View file

@ -1,160 +0,0 @@
# Build X and Compare User Guide
## Overview
The **Build X and Compare** feature allows you to build multiple decks using the same configuration and compare the results side-by-side. This is useful for:
- **Seeing variance**: Understand which cards are consistent vs. which cards vary due to RNG
- **Finding optimal builds**: Compare multiple results to pick the best deck
- **Analyzing synergies**: Use the Synergy Builder to create an optimized "best-of" deck
## Quick Start
### 1. Build Multiple Decks
1. Click **New Deck** to open the deck builder modal
2. Configure your commander, themes, ideals, and bracket as normal
3. At the bottom of the modal, adjust the **"Number of decks to build"** slider (1-10)
- Setting this to 2 or more enables batch build mode
4. Click **Quick Build** - the "Create" button is hidden for batch builds
**Note**: All builds use the exact same configuration. There are no variations in commander, themes, or ideals - you're simply running the same build multiple times to see different card selections.
### 2. Track Progress
After starting a batch build, you'll see a progress screen showing:
- **Progress bar**: Visual indicator of completion
- **Build status**: "Completed X of Y builds..."
- **Time estimate**: Dynamically adjusted based on commander color count
- 1-2 colors: 1-3 minutes
- 3 colors: 2-4 minutes
- 4-5 colors: 3-5 minutes
- **First deck time**: The first deck takes ~55-60% of total time
### 3. Compare Results
Once all builds complete, you'll be redirected to the **Comparison View** with:
#### Overview Stats
- **Unique Cards Total**: All different cards across all builds
- **In All Builds**: Cards that appear in every single deck
- **In Most Builds (80%+)**: High-frequency cards
- **In Some Builds**: Medium-frequency cards
- **In Few Builds**: Low-frequency cards
#### Most Common Cards
Shows the top 20 cards by appearance frequency, excluding guaranteed cards like:
- Basic lands
- Staple lands (Command Tower, Reliquary Tower, etc.)
- Must-include cards (if using the include/exclude feature)
- Fetch lands
**Tip**: Hover over any card name to see the card image!
#### Individual Build Summaries
Each build shows:
- Total card count and breakdown (Creatures, Lands, Artifacts, etc.)
- Expandable card list with full deck contents
## Using the Synergy Builder
The **Synergy Builder** analyzes all builds and creates an optimized "best-of" deck using the most synergistic cards.
### How It Works
The Synergy Builder scores each card based on:
1. **Frequency (50%)**: How often the card appears across builds
- Cards in 80%+ of builds get a 10% bonus
2. **EDHREC Rank (25%)**: Community popularity data
3. **Theme Tags (25%)**: Alignment with your chosen themes
### Building a Synergy Deck
1. From the comparison view, click **✨ Build Synergy Deck**
2. Wait a few seconds while the synergy deck is generated
3. Review the results:
- **Synergy Preview**: Shows the full deck with color-coded synergy scores
- 🟢 Green (80-100): High synergy
- 🔵 Blue (60-79): Good synergy
- 🟡 Yellow (40-59): Medium synergy
- 🟠 Orange (20-39): Low synergy
- 🔴 Red (0-19): Very low synergy
- Cards are organized by type (Creature, Artifact, Enchantment, etc.)
- Each section can be expanded/collapsed for easier viewing
### Exporting the Synergy Deck
1. Click **Export Synergy Deck** at the bottom of the synergy preview
2. **Warning**: This will delete the individual batch build files and disable batch export
3. Confirm the export to download a ZIP containing:
- **SynergyDeck_CommanderName.csv**: Deck list in CSV format
- **SynergyDeck_CommanderName.txt**: Plain text deck list
- **summary.json**: Deck statistics and metadata
- **compliance.json**: Bracket compliance information
- **synergy_metadata.json**: Synergy scores and build source data
## Additional Actions
### Rebuild X Times
Click **🔄 Rebuild Xx** to run the same configuration again with the same build count. This creates a new batch and redirects to the progress page.
### Export All Decks
Click **Export All Decks as ZIP** to download all individual build files as a ZIP archive containing:
- CSV and TXT files for each build (Build_1_CommanderName.csv, etc.)
- `batch_summary.json` with metadata
**Note**: This button is disabled after exporting a synergy deck.
## Performance Notes
- **Parallel execution**: Builds run concurrently (max 5 at a time) for faster results
- **Build time scales**: More colors = longer build times
- Mono/dual color: ~1 minute per 10 builds
- 3 colors: ~2-3 minutes per 10 builds
- 4-5 colors: ~3-4 minutes per 10 builds
- **First deck overhead**: The first deck in a batch takes longer due to setup
## Feature Flag
To disable this feature entirely, set `ENABLE_BATCH_BUILD=0` in your environment variables or `.env` file. This will:
- Hide the "Number of decks to build" slider
- Force all builds to be single-deck builds
- Hide comparison and synergy features
## Tips & Best Practices
1. **Start small**: Try 3-5 builds first to get a feel for variance
2. **Use for optimization**: Build 5-10 decks and pick the best result
3. **Check consistency**: Cards appearing in 80%+ of builds are core to your strategy
4. **Analyze variance**: Cards appearing in <50% of builds might be too situational
5. **Synergy builder**: Best results with 5-10 source builds
6. **Export early**: Export individual builds before creating synergy deck if you want both
## Troubleshooting
### Builds are slow
- Check your commander's color count - 4-5 color decks take longer
- System resources - close other applications
- First build takes longest - wait for completion before judging speed
### All builds look identical
- Rare but possible - try adjusting themes or ideals for more variety
- Check if you're using strict constraints (e.g., "owned cards only" with limited pool)
### Synergy deck doesn't meet ideals
- The synergy builder aims for ±2 cards per category
- If source builds don't have enough variety, it may relax constraints
- Try building more source decks (7-10) for better card pool
### Export button disabled
- You've already exported a synergy deck, which deletes individual batch files
- Click "Rebuild Xx" to create a new batch if you need the files again
## See Also
- [Docker Setup Guide](../DOCKER.md) - Environment variables and configuration
- [README](../../README.md) - General project documentation
- [Changelog](../../CHANGELOG.md) - Feature updates and changes

View file

@ -1,104 +0,0 @@
# Web UI Deep Dive
A closer look at the rich interactions available in the MTG Python Deckbuilder Web UI. Use this guide after you are comfortable with the basic homepage flows described in the README.
## Table of contents
- [Unified New Deck modal](#unified-new-deck-modal)
- [Stage 5 tools: lock, replace, compare, permalinks](#stage-5-tools-lock-replace-compare-permalinks)
- [Multi-copy archetype packages](#multi-copy-archetype-packages)
- [Bracket compliance and skipped stages](#bracket-compliance-and-skipped-stages)
- [Build options: owned-only and prefer-owned](#build-options-owned-only-and-prefer-owned)
- [Visual summaries](#visual-summaries)
- [Combos & synergies](#combos--synergies)
- [Owned library page](#owned-library-page)
- [Finished decks workspace](#finished-decks-workspace)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Virtualization, tagging, and performance](#virtualization-tagging-and-performance)
- [Diagnostics and logs](#diagnostics-and-logs)
---
## Unified New Deck modal
The first three steps of deckbuilding live inside a single modal:
1. **Search for a commander** autocomplete prioritizes color identity matches; press Enter to grab the top result.
2. **Pick primary/secondary/tertiary themes** the modal displays your selections in order so you can revisit them quickly.
3. **Choose a bracket** labels such as “Bracket 3: Upgraded” clarify power bands. Bracket 3 is the default tier for new builds.
Optional inputs:
- **Deck name** becomes the export filename stem and is reused in Finished Decks banners.
- **Combo auto-complete** and other preferences persist between runs.
Once you submit, the modal closes and the build starts immediately—no extra confirmation screen.
## Stage 5 tools: lock, replace, compare, permalinks
Stage 5 is the iterative workspace for tuning the deck:
- **Lock** a card by clicking the padlock or the card artwork. Locked cards persist across rerolls and show a “Last action” chip for quick confirmation.
- **Replace** opens the Alternatives drawer. Filters include Owned-only, role alignment, and bracket compliance. The system skips commanders, locked cards, just-added cards, and anything already in the list.
- **Permalink** buttons appear in Stage 5 and Finished Decks. Share a build (commander, themes, bracket, ideals, flags) or restore one by pasting a permalink back into the app.
- **Compare** mode lives in Finished Decks. Pick two builds (quick actions select the latest pair) and triage changes via Changed-only, Copy summary, or download the diff as TXT.
## Multi-copy archetype packages
When a commander + theme combination suggests a multi-copy strategy (e.g., Persistent Petitioners, Shadowborn Apostles), the UI offers an optional package:
- Choose the desired quantity (bounded by printed limits) and optionally add **Thrumming Stone** when it synergizes.
- Packages are inserted before other stages so target counts adjust appropriately.
- A safety clamp trims overflow to keep the deck at 100 cards; the stage displays a “Clamped N” indicator if it triggers.
- You can dismiss the modal, and we wont re-prompt unless your selections change.
## Bracket compliance and skipped stages
- Bracket policy enforcement prunes disallowed categories before stage execution. Violations block reruns until you resolve them.
- Enforcement options: keep the panel collapsed when compliant, auto-open with a colored status chip (green/amber/red) when action is needed.
- Enable auto-enforcement by setting `WEB_AUTO_ENFORCE=1`.
- Toggle **Show skipped stages** to surface steps that added zero cards, making it easier to review the full pipeline.
## Build options: owned-only and prefer-owned
The modal includes toggles for **Use only owned cards** and **Prefer owned cards**:
- Owned-only builds pull strictly from the inventory in `owned_cards/` (commander exempt).
- Prefer-owned bumps owned cards slightly in the scoring pipeline but still allows unowned all-stars when necessary.
- Both modes respect the Owned Library filters and show Owned badges in the exported CSV (including the `Owned` column when you disable the mode).
## Visual summaries
Stage 5 displays multiple data visualizations that cross-link to the card list:
- **Mana curve** hover a bar to highlight matching cards in list and thumbnail views.
- **Color requirements vs. sources** pips show requirements; sources include non-land producers and an optional `C` (colorless) toggle.
- **Tooltips** each tooltip lists contributing cards and offers a copy-to-clipboard action.
- Visual polish includes lazy-loaded thumbnails, blur-up transitions, and accessibility tweaks that respect `prefers-reduced-motion`.
## Combos & synergies
The builder detects curated two-card combos and synergy pairs in the final deck:
- Chips display badges such as “cheap” or “setup” with hover previews for each card and a split preview when hovering the entire row.
- Enable **Auto-complete combos** to add missing partners before theme filling. Configure target count, balance (early/late/mix), and preference weighting.
- Color identity restrictions keep the algorithm from suggesting off-color partners.
## Owned library page
Open the Owned tile to manage uploaded inventories:
- Upload `.txt` or `.csv` files with one card per line. The app enriches and deduplicates entries on ingestion.
- The page includes sortable columns, exact color-identity filters (including four-color combos), and an export button.
- Large collections benefit from virtualization when `WEB_VIRTUALIZE=1`.
## Finished decks workspace
- Browse historical builds with filterable theme chips.
- Each deck offers Download TXT, Copy summary, Open permalink, and Compare actions.
- Locks, replace history, and compliance metadata are stored per deck and surface alongside the exports.
## Keyboard shortcuts
- **Enter** selects the first commander suggestion while searching.
- Inside Stage 5 lists: **L** locks/unlocks the focused card, **R** opens the Replace drawer, and **C** copies the permalink.
- Browser autofill is disabled in the modal to keep searches clean.
## Virtualization, tagging, and performance
- `WEB_TAG_PARALLEL=1` with `WEB_TAG_WORKERS=4` (compose default) speeds up initial data preparation. The UI falls back to sequential tagging if workers fail to start.
- `WEB_VIRTUALIZE=1` enables virtualized grids in Stage 5 and the Owned library, smoothing large decks or libraries.
- Diagnostics overlays: enable `SHOW_DIAGNOSTICS=1`, then press **v** inside a virtualized grid to inspect render ranges, row counts, and paint timings.
## Diagnostics and logs
- `SHOW_DIAGNOSTICS=1` unlocks the `/diagnostics` page with system summaries (`/status/sys`), feature flags, and per-request `X-Request-ID` headers.
- Supplemental theme telemetry lives at `/status/theme_metrics` (enabled with `ENABLE_CUSTOM_THEMES=1`); the diagnostics page renders commander themes, user-supplied themes, merged totals, and unresolved counts using the `userThemes`/`themeCatalogVersion` metadata exported from builds.
- `SHOW_LOGS=1` turns on the `/logs` viewer with level & keyword filters, auto-refresh, and copy-to-clipboard.
- Health probes live at `/healthz` and return `{status, version, uptime_seconds}` for integration with uptime monitors.

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

55
tailwind.config.js Normal file
View file

@ -0,0 +1,55 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./code/web/templates/**/*.html",
"./code/web/static/**/*.js",
"./code/web/static/**/*.ts",
],
theme: {
extend: {
colors: {
// MTG color identity colors
'mtg-white': '#F8F6D8',
'mtg-blue': '#0E68AB',
'mtg-black': '#150B00',
'mtg-red': '#D3202A',
'mtg-green': '#00733E',
// Theme colors (match existing CSS variables)
'bg-primary': 'var(--bg-primary)',
'bg-secondary': 'var(--bg-secondary)',
'bg-tertiary': 'var(--bg-tertiary)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'border-color': 'var(--border-color)',
'accent-primary': 'var(--accent-primary)',
'accent-secondary': 'var(--accent-secondary)',
'panel-bg': 'var(--panel-bg)',
'panel-border': 'var(--panel-border)',
// Button colors
'btn-forward': '#3b82f6', // blue
'btn-backward': '#6b7280', // gray
},
spacing: {
// Card image sizes
'card-prominent': '360px',
'card-list': '160px',
},
zIndex: {
// Z-index stacking system
'base': '0',
'dropdown': '10',
'sticky': '20',
'modal-backdrop': '40',
'modal': '50',
'toast': '60',
'tooltip': '70',
},
},
screens: {
'mobile-max': {'max': '900px'},
},
},
plugins: [],
}