diff --git a/.env.example b/.env.example
index 75119f7..5921ede 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index f8e1a3c..6de24ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
RELEASE_NOTES.md
test.py
+test_*.py
!test_exclude_cards.txt
!test_include_exclude_config.json
@@ -40,4 +41,14 @@ logs/
logs/*
!logs/perf/
logs/perf/*
-!logs/perf/theme_preview_warm_baseline.json
\ No newline at end of file
+!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
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6db31c..4fbd36b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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_
diff --git a/DOCKER.md b/DOCKER.md
index 398140c..99c9907 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -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. |
diff --git a/Dockerfile b/Dockerfile
index 7f6f0ce..1f76105 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index e979b3a..5d46b02 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md
index c71d6af..ce0fdbf 100644
--- a/RELEASE_NOTES_TEMPLATE.md
+++ b/RELEASE_NOTES_TEMPLATE.md
@@ -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_
diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py
index 86dedd4..b941f30 100644
--- a/code/deck_builder/background_loader.py
+++ b/code/deck_builder/background_loader.py
@@ -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()
diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py
index 36ab3fe..a1ae03a 100644
--- a/code/deck_builder/builder_utils.py
+++ b/code/deck_builder/builder_utils.py
@@ -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
diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py
index f5808bc..3a752f6 100644
--- a/code/deck_builder/partner_selection.py
+++ b/code/deck_builder/partner_selection.py
@@ -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)
diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py
index 97e691b..d0d05ab 100644
--- a/code/deck_builder/phases/phase6_reporting.py
+++ b/code/deck_builder/phases/phase6_reporting.py
@@ -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),
diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py
new file mode 100644
index 0000000..08a7c22
--- /dev/null
+++ b/code/file_setup/image_cache.py
@@ -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()
diff --git a/code/file_setup/scryfall_bulk_data.py b/code/file_setup/scryfall_bulk_data.py
new file mode 100644
index 0000000..fd41d90
--- /dev/null
+++ b/code/file_setup/scryfall_bulk_data.py
@@ -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
diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py
index 0b01e21..62a8165 100644
--- a/code/file_setup/setup.py
+++ b/code/file_setup/setup.py
@@ -349,6 +349,44 @@ def initial_setup() -> None:
logger.info(f" Raw: {raw_path}")
logger.info(f" Processed: {processed_path}")
logger.info("=" * 80)
+
+ # Step 3: Optional image caching (if enabled)
+ try:
+ from code.file_setup.image_cache import ImageCache
+ cache = ImageCache()
+
+ if cache.is_enabled():
+ logger.info("=" * 80)
+ logger.info("Card image caching enabled - starting download")
+ logger.info("=" * 80)
+
+ # Download bulk data
+ logger.info("Downloading Scryfall bulk data...")
+ cache.download_bulk_data()
+
+ # Download images
+ logger.info("Downloading card images (this may take 1-2 hours)...")
+
+ def progress(current, total, card_name):
+ if current % 100 == 0: # Log every 100 cards
+ pct = (current / total) * 100
+ logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}")
+
+ stats = cache.download_images(progress_callback=progress)
+
+ logger.info("=" * 80)
+ logger.info("✓ Image cache complete")
+ logger.info(f" Downloaded: {stats['downloaded']}")
+ logger.info(f" Skipped: {stats['skipped']}")
+ logger.info(f" Failed: {stats['failed']}")
+ logger.info("=" * 80)
+ else:
+ logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)")
+ logger.info("Images will be fetched from Scryfall API on demand")
+
+ except Exception as e:
+ logger.error(f"Failed to cache images (continuing anyway): {e}")
+ logger.error("Images will be fetched from Scryfall API on demand")
def regenerate_processed_parquet() -> None:
diff --git a/code/tagging/multi_face_merger.py b/code/tagging/multi_face_merger.py
index 0dd2753..deb31ac 100644
--- a/code/tagging/multi_face_merger.py
+++ b/code/tagging/multi_face_merger.py
@@ -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
diff --git a/code/web/app.py b/code/web/app.py
index ac2854b..7dd47b9 100644
--- a/code/web/app.py
+++ b/code/web/app.py
@@ -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:
diff --git a/code/web/routes/api.py b/code/web/routes/api.py
new file mode 100644
index 0000000..157344b
--- /dev/null
+++ b/code/web/routes/api.py
@@ -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)
diff --git a/code/web/routes/build.py b/code/web/routes/build.py
index 5a80829..18e01c3 100644
--- a/code/web/routes/build.py
+++ b/code/web/routes/build.py
@@ -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
diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py
index 9cbe635..f590c39 100644
--- a/code/web/routes/setup.py
+++ b/code/web/routes/setup.py
@@ -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()
})
diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py
index 32cb279..a4fb8b2 100644
--- a/code/web/routes/themes.py
+++ b/code/web/routes/themes.py
@@ -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("
Theme not found.
", 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) ---
diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py
index a37a540..241a1c7 100644
--- a/code/web/services/build_utils.py
+++ b/code/web/services/build_utils.py
@@ -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)):
diff --git a/code/web/static/components.js b/code/web/static/components.js
new file mode 100644
index 0000000..de4021c
--- /dev/null
+++ b/code/web/static/components.js
@@ -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 = '';
+ }
+
+ let roleHTML = '';
+ if (role) {
+ roleHTML = ``;
+ }
+
+ let flipButtonHTML = '';
+ if (isDFC) {
+ flipButtonHTML = `
+
+ `;
+ }
+
+ popup.innerHTML = `
+
+
+ `;
+
+ 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
+ };
+}
diff --git a/code/web/static/css_backup_pre_tailwind/styles.css b/code/web/static/css_backup_pre_tailwind/styles.css
new file mode 100644
index 0000000..eda7352
--- /dev/null
+++ b/code/web/static/css_backup_pre_tailwind/styles.css
@@ -0,0 +1,1208 @@
+/* Base */
+:root{
+ /* MTG color palette (approx from provided values) */
+ --banner-h: 52px;
+ --sidebar-w: 260px;
+ --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);
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
+ --ring: #60a5fa; /* focus ring */
+ --ok: #16a34a; /* success */
+ --warn: #f59e0b; /* warning */
+ --err: #ef4444; /* error */
+ /* Surface overrides for specific regions (default to panel) */
+ --surface-banner: var(--panel);
+ --surface-banner-text: var(--text);
+ --surface-sidebar: var(--panel);
+ --surface-sidebar-text: var(--text);
+}
+
+/* Light blend between Slate and Parchment (leans gray) */
+[data-theme="light-blend"]{
+ --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
+ --panel: #ffffff; /* crisp panels for readability */
+ --text: #0b0d12;
+ --muted: #6b655d; /* slightly warm muted */
+ --border: #d6d1c7; /* neutral warm-gray border */
+ /* Slightly darker banner/sidebar for separation */
+ --surface-banner: #1a1b1e;
+ --surface-sidebar: #1a1b1e;
+ --surface-banner-text: #e8e8e8;
+ --surface-sidebar-text: #e8e8e8;
+}
+
+[data-theme="dark"]{
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
+}
+[data-theme="high-contrast"]{
+ --bg: #000;
+ --panel: #000;
+ --text: #fff;
+ --muted: #e5e7eb;
+ --border: #fff;
+ --ring: #ff0;
+}
+[data-theme="cb-friendly"]{
+ /* Tweak accents for color-blind friendliness */
+ --green-main: #2e7d32; /* darker green */
+ --red-main: #c62828; /* deeper red */
+ --blue-main: #1565c0; /* balanced blue */
+}
+*{box-sizing:border-box}
+html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;}
+body {
+ font-family: system-ui, Arial, sans-serif;
+ margin: 0;
+ color: var(--text);
+ background: var(--bg);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+/* Honor HTML hidden attribute across the app */
+[hidden] { display: none !important; }
+/* Accessible focus ring for keyboard navigation */
+.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+/* Top banner */
+.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
+.top-banner{ min-height: var(--banner-h); }
+.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; }
+.top-banner .top-inner > div{ min-width:0; }
+@media (max-width: 1100px){
+ .top-banner .top-inner{ grid-auto-rows:auto; }
+ .top-banner .top-inner select{ max-width:140px; }
+}
+.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
+.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
+.banner-status.busy{ color:#fbbf24; }
+.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
+.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
+
+/* Layout */
+.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
+.sidebar{
+ background: var(--surface-sidebar);
+ color: var(--surface-sidebar-text);
+ border-right: 1px solid var(--border);
+ padding: 1rem;
+ position: fixed;
+ top: var(--banner-h);
+ left: 0;
+ bottom: 0;
+ overflow: auto;
+ width: var(--sidebar-w);
+ z-index: 9; /* below the banner (z=10) */
+ box-shadow: 2px 0 10px rgba(0,0,0,.18);
+ display: flex;
+ flex-direction: column;
+}
+.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
+
+/* Collapsible sidebar behavior */
+body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); }
+body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; }
+body.nav-collapsed .content{ grid-column: 2; }
+body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; }
+body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; }
+/* Smooth hide/show on mobile while keeping fixed positioning */
+.sidebar{ transition: transform .2s ease-out, visibility .2s linear; }
+/* Suppress sidebar transitions during page load to prevent pop-in */
+body.no-transition .sidebar{ transition: none !important; }
+/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */
+body.htmx-settling .sidebar{ transition: none !important; }
+body.htmx-settling .layout{ transition: none !important; }
+body.htmx-settling .content{ transition: none !important; }
+body.htmx-settling *{ transition-duration: 0s !important; }
+
+/* Mobile tweaks */
+@media (max-width: 900px){
+ :root{ --sidebar-w: 240px; }
+ .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; }
+ .banner-status{ padding-left: .5rem; }
+ .layout{ grid-template-columns: 0 1fr; }
+ .sidebar{ transform: translateX(-100%); visibility: hidden; }
+ body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
+ body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
+ .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
+ .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); }
+ /* Spacing tweaks: tighter left, larger gaps between visible items */
+ .top-banner .top-inner > div{ gap: 25px !important; }
+ .top-banner .top-inner > div:first-child{ padding-left: 0 !important; }
+ /* Mobile: show only Menu, Title, and Theme selector */
+ #btn-open-permalink{ display:none !important; }
+ #banner-status{ display:none !important; }
+ #health-dot{ display:none !important; }
+ .top-banner #theme-reset{ display:none !important; }
+}
+
+/* Additional mobile spacing for bottom floating controls */
+@media (max-width: 720px) {
+ .content {
+ padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */
+ }
+}
+
+.brand h1{ display:none; }
+.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
+.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
+.dot.green{ background: var(--green-main); }
+.dot.blue{ background: var(--blue-main); }
+.dot.red{ background: var(--red-main); }
+.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
+.dot.black{ background: var(--black-light); }
+
+.nav{ display:flex; flex-direction:column; gap:.35rem; }
+.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
+.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
+
+/* Sidebar theme controls anchored at bottom */
+.sidebar .nav { flex: 1 1 auto; }
+.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
+.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; }
+.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; }
+.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; }
+.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); }
+
+/* Simple two-column layout for inspect panel */
+.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
+.two-col .grow { min-width: 0; }
+.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); }
+@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
+
+/* Left-rail variant puts the image first */
+.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
+/* Ensure left-rail variant also collapses to 1 column on small screens */
+@media (max-width: 900px){
+ .two-col.two-col-left-rail{ grid-template-columns: 1fr; }
+ /* So the commander image doesn't dominate on mobile */
+ .two-col .card-preview{ max-width: 360px; margin: 0 auto; }
+ .two-col .card-preview img{ width: 100%; height: auto; }
+}
+.card-preview.card-sm{ max-width:200px; }
+
+/* Buttons, inputs */
+button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
+button:hover{ filter:brightness(1.05); }
+/* Anchor-style buttons */
+.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
+.btn:hover{ filter:brightness(1.05); text-decoration:none; }
+.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
+label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
+.color-identity{ display:inline-flex; align-items:center; gap:.35rem; }
+.color-identity .mana + .mana{ margin-left:4px; }
+.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; }
+.mana-W{ background:#f9fafb; border-color:#d1d5db; }
+.mana-U{ background:#3b82f6; border-color:#1d4ed8; }
+.mana-B{ background:#111827; border-color:#1f2937; }
+.mana-R{ background:#ef4444; border-color:#b91c1c; }
+.mana-G{ background:#10b981; border-color:#047857; }
+.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
+select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
+fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
+small, .muted{ color: var(--muted); }
+.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
+.partner-preview[hidden]{ display:none !important; }
+.partner-preview__header{ font-weight:600; }
+.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; }
+.partner-preview__art{ flex:0 0 auto; }
+.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); }
+.partner-preview__details{ flex:1 1 180px; min-width:0; }
+.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }
+.partner-preview__pairing{ margin-top:.35rem; }
+.partner-preview__themes{ margin-top:.35rem; font-size:12px; }
+.partner-preview--static{ margin-bottom:.5rem; }
+.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); }
+
+/* Toasts */
+.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
+.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
+.toast.hide{ opacity:0; transform: translateY(6px); }
+.toast.success{ border-color: rgba(22,163,74,.4); }
+.toast.error{ border-color: rgba(239,68,68,.45); }
+.toast.warn{ border-color: rgba(245,158,11,.45); }
+
+/* Skeletons */
+[data-skeleton]{ position: relative; }
+[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; }
+[data-skeleton-placeholder]{ display:none; pointer-events:none; }
+[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; }
+[data-skeleton][data-skeleton-overlay="false"]::after,
+[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; }
+[data-skeleton]::after{
+ content: '';
+ position: absolute; inset: 0;
+ border-radius: 8px;
+ background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
+ background-size: 200% 100%;
+ animation: shimmer 1.1s linear infinite;
+ display: none;
+}
+[data-skeleton].is-loading::after{ display:block; }
+[data-skeleton].is-loading::before{
+ content: attr(data-skeleton-label);
+ position:absolute;
+ top:50%;
+ left:50%;
+ transform:translate(-50%, -50%);
+ color: var(--muted);
+ font-size:.85rem;
+ text-align:center;
+ line-height:1.4;
+ max-width:min(92%, 360px);
+ padding:.3rem .5rem;
+ pointer-events:none;
+ z-index:1;
+ filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
+}
+[data-skeleton][data-skeleton-label=""]::before{ content:''; }
+@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
+
+/* Banner */
+.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
+.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
+.banner .subtitle{ color: var(--muted); font-size:.95rem; }
+
+/* Home actions */
+.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
+.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
+.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); }
+.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
+
+/* Card grid for added cards (responsive, compact tiles) */
+.card-grid{
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
+ gap: .5rem;
+ margin-top:.5rem;
+ justify-content: start; /* pack as many as possible per row */
+ /* Prevent scroll chaining bounce that can cause flicker near bottom */
+ overscroll-behavior: contain;
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 640px 420px;
+}
+@media (max-width: 420px){
+ .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .card-tile{ width: 100%; }
+ .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; }
+}
+.card-tile{
+ width:170px;
+ position: relative;
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.25rem .25rem .4rem;
+ text-align:center;
+}
+.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
+.card-tile.locked{
+ /* Subtle yellow/goldish-white accent for locked cards */
+ border-color: #f5e6a8; /* soft parchment gold */
+ box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
+}
+.card-tile.must-include{
+ border-color: rgba(74,222,128,.85);
+ box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
+}
+.card-tile.must-exclude{
+ border-color: rgba(239,68,68,.85);
+ box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
+ opacity: .95;
+}
+.card-tile.must-include.must-exclude{
+ border-color: rgba(249,115,22,.85);
+ box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset;
+}
+.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
+.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
+.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
+
+.must-have-controls{
+ display:flex;
+ justify-content:center;
+ gap:.35rem;
+ flex-wrap:wrap;
+ margin-top:.35rem;
+}
+.must-have-btn{
+ border:1px solid var(--border);
+ background:rgba(30,41,59,.6);
+ color:#f8fafc;
+ font-size:11px;
+ text-transform:uppercase;
+ letter-spacing:.06em;
+ padding:.25rem .6rem;
+ border-radius:9999px;
+ cursor:pointer;
+ transition: all .18s ease;
+}
+.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{
+ border-color: rgba(74,222,128,.75);
+ background: rgba(74,222,128,.18);
+ color: #bbf7d0;
+ box-shadow: 0 0 0 1px rgba(16,185,129,.25);
+}
+.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{
+ border-color: rgba(239,68,68,.75);
+ background: rgba(239,68,68,.18);
+ color: #fecaca;
+ box-shadow: 0 0 0 1px rgba(239,68,68,.25);
+}
+.must-have-btn:focus-visible{
+ outline:2px solid rgba(59,130,246,.6);
+ outline-offset:2px;
+}
+.card-tile.must-exclude .must-have-btn.include[data-active="0"],
+.card-tile.must-include .must-have-btn.exclude[data-active="0"]{
+ opacity:.65;
+}
+
+.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
+.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
+
+/* Shared ownership badge for card tiles and stacked images */
+.owned-badge{
+ position:absolute;
+ top:6px;
+ left:6px;
+ background:rgba(17,24,39,.9);
+ color:#e5e7eb;
+ border:1px solid var(--border);
+ border-radius:12px;
+ font-size:12px;
+ line-height:18px;
+ height:18px;
+ min-width:18px;
+ padding:0 6px;
+ text-align:center;
+ pointer-events:none;
+ z-index:2;
+}
+
+/* Step 1 candidate grid (200px-wide scaled images) */
+.candidate-grid{
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap:.75rem;
+}
+.candidate-tile{
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:8px;
+ padding:.4rem;
+}
+.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
+.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; }
+.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
+.candidate-tile .name{ font-weight:600; font-size:.95rem; }
+.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
+
+/* Deck summary: highlight game changers */
+.game-changer { color: var(--green-main); }
+.stack-card.game-changer { outline: 2px solid var(--green-main); }
+
+/* Image button inside card tiles */
+.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
+
+/* Stage Navigator */
+.stage-nav { margin:.5rem 0 1rem; }
+.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
+.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
+.stage-nav .stage-item.done .stage-link { opacity:.75; }
+.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
+.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
+.stage-nav .name { font-size:12px; }
+
+/* Build controls sticky box tweaks */
+.build-controls {
+ position: sticky;
+ top: calc(var(--banner-offset, 48px) + 6px);
+ z-index: 100;
+ background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin: 0.5rem 0;
+ box-shadow: 0 4px 12px rgba(0,0,0,.25);
+}
+
+@media (max-width: 1024px){
+ :root { --banner-offset: 56px; }
+ .build-controls {
+ position: fixed !important; /* Fixed to viewport instead of sticky */
+ bottom: 0 !important; /* Anchor to bottom of screen */
+ left: 0 !important;
+ right: 0 !important;
+ top: auto !important; /* Override top positioning */
+ border-radius: 0 !important; /* Remove border radius for full width */
+ margin: 0 !important; /* Remove margins for full edge-to-edge */
+ padding: 0.5rem !important; /* Reduced padding */
+ box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */
+ border-left: none !important;
+ border-right: none !important;
+ border-bottom: none !important; /* Remove bottom border */
+ background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
+ z-index: 1000 !important; /* Higher z-index to ensure it's above content */
+ }
+}
+@media (min-width: 721px){
+ :root { --banner-offset: 48px; }
+}
+
+/* Progress bar */
+.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
+.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
+.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
+
+/* Chips */
+.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
+.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
+
+/* Cards toolbar */
+.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
+.cards-toolbar input[type="text"]{ min-width: 220px; }
+.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
+.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
+
+/* Collapse groups and reason toggle */
+.group{ margin:.5rem 0; }
+.group-header{ display:flex; align-items:center; gap:.5rem; }
+.group-header h5{ margin:.4rem 0; }
+.group-header .count{ color: var(--muted); font-size:12px; }
+.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
+.group-grid[data-collapsed]{ display:none; }
+.hide-reasons .card-tile .reason{ display:none; }
+.card-tile.force-show .reason{ display:block !important; }
+.card-tile.force-hide .reason{ display:none !important; }
+.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
+.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
+.chips-inline .chip{ cursor:pointer; user-select:none; }
+
+/* Inline error banner */
+.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
+.inline-error-banner .muted{ color:#fda4af; }
+
+/* Alternatives panel */
+.alts ul{ list-style:none; padding:0; margin:0; }
+.alts li{ display:flex; align-items:center; gap:.4rem; }
+/* LQIP blur/fade-in for thumbnails */
+img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
+img.lqip.loaded { filter: blur(0); opacity: 1; }
+
+/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
+@media (prefers-reduced-motion: reduce) {
+ * { scroll-behavior: auto !important; }
+ img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
+}
+
+/* Virtualization wrapper should mirror grid to keep multi-column flow */
+.virt-wrapper { display: grid; }
+
+/* Mobile responsive fixes for horizontal scrolling issues */
+@media (max-width: 768px) {
+ /* Prevent horizontal overflow */
+ html, body {
+ overflow-x: hidden !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ }
+
+ /* Test hand responsive adjustments */
+ #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; }
+
+ /* Modal & form layout fixes (original block retained inside media query) */
+ /* Fix modal layout on mobile */
+ .modal {
+ padding: 10px !important;
+ box-sizing: border-box;
+ }
+ .modal-content {
+ width: 100% !important;
+ max-width: calc(100vw - 20px) !important;
+ box-sizing: border-box !important;
+ overflow-x: hidden !important;
+ }
+ /* Force single column for include/exclude grid */
+ .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; }
+ /* Fix basics grid */
+ .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; }
+ /* Ensure all inputs and textareas fit properly */
+ .modal input,
+ .modal textarea,
+ .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; }
+ /* Fix chips containers */
+ .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; }
+ /* Ensure fieldsets don't overflow */
+ .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; }
+ /* Fix any inline styles that might cause overflow */
+ .modal fieldset > div,
+ .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; }
+}
+
+@media (max-width: 640px){
+ #test-hand{ --card-w: 150px !important; --card-h: 210px !important; }
+ /* Generic stack shrink */
+ .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; }
+}
+
+@media (max-width: 560px){
+ #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; }
+ #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; }
+ #test-hand .stack-card{ flex:0 0 auto; }
+ .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; }
+}
+
+@media (max-width: 480px) {
+ .modal-content {
+ padding: 12px !important;
+ margin: 5px !important;
+ }
+
+ .modal fieldset {
+ padding: 8px !important;
+ margin: 6px 0 !important;
+ }
+
+ /* Enhanced mobile build controls */
+ .build-controls {
+ flex-direction: column !important;
+ gap: 0.25rem !important; /* Reduced gap */
+ align-items: stretch !important;
+ padding: 0.5rem !important; /* Reduced padding */
+ }
+
+ /* Two-column grid layout for mobile build controls */
+ .build-controls {
+ display: grid !important;
+ grid-template-columns: 1fr 1fr !important; /* Two equal columns */
+ grid-gap: 0.25rem !important;
+ align-items: stretch !important;
+ }
+
+ .build-controls form {
+ display: contents !important; /* Allow form contents to participate in grid */
+ width: auto !important;
+ }
+
+ .build-controls button {
+ flex: none !important;
+ padding: 0.4rem 0.5rem !important; /* Much smaller padding */
+ font-size: 12px !important; /* Smaller font */
+ min-height: 36px !important; /* Smaller minimum height */
+ line-height: 1.2 !important;
+ width: 100% !important; /* Full width within grid cell */
+ box-sizing: border-box !important;
+ white-space: nowrap !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ /* Hide non-essential elements on mobile to keep it clean */
+ .build-controls .sep,
+ .build-controls .replace-toggle,
+ .build-controls label[style*="margin-left"] {
+ display: none !important;
+ }
+
+ .build-controls .sep {
+ display: none !important; /* Hide separators on mobile */
+ }
+}
+
+/* Desktop sizing for Test Hand */
+@media (min-width: 900px) {
+ #test-hand { --card-w: 280px !important; --card-h: 392px !important; }
+}
+
+/* Analytics accordion styling */
+.analytics-accordion {
+ transition: all 0.2s ease;
+}
+
+.analytics-accordion summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.analytics-accordion summary:hover {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+.analytics-accordion summary:active {
+ transform: scale(0.99);
+}
+
+.analytics-accordion[open] summary {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ margin-bottom: 0;
+}
+
+.analytics-accordion .analytics-content {
+ animation: accordion-slide-down 0.3s ease-out;
+}
+
+@keyframes accordion-slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.analytics-placeholder .skeleton-pulse {
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+/* Ideals Slider Styling */
+.ideals-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 6px;
+ background: var(--border);
+ border-radius: 3px;
+ outline: none;
+}
+
+.ideals-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border-radius: 50%;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.ideals-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+}
+
+.ideals-slider::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.ideals-slider::-moz-range-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+}
+
+.slider-value {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+/* ========================================
+ Card Browser Styles
+ ======================================== */
+
+/* Card browser container */
+.card-browser-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+/* Filter panel */
+.card-browser-filters {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+.filter-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.filter-row label {
+ font-weight: 600;
+ min-width: 80px;
+ color: var(--text);
+ font-size: 0.95rem;
+}
+
+.filter-row select,
+.filter-row input[type="text"],
+.filter-row input[type="search"] {
+ flex: 1;
+ min-width: 150px;
+ max-width: 300px;
+}
+
+/* Search bar styling */
+.card-search-wrapper {
+ position: relative;
+ flex: 1;
+ max-width: 100%;
+}
+
+.card-search-wrapper input[type="search"] {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 1rem;
+}
+
+/* Results count and info bar */
+.card-browser-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+}
+
+.results-count {
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+.page-indicator {
+ font-size: 0.95rem;
+ color: var(--text);
+ font-weight: 600;
+}
+
+/* Card browser grid */
+.card-browser-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 240px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-height: 480px;
+ justify-content: start;
+}
+
+/* Individual card tile in browser */
+.card-browser-tile {
+ break-inside: avoid;
+ display: flex;
+ flex-direction: column;
+ background: var(--card-bg, #1a1d24);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ cursor: pointer;
+}
+
+.card-browser-tile:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%);
+}
+
+.card-browser-tile-image {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 488/680;
+ overflow: hidden;
+ background: #0a0b0e;
+}
+
+.card-browser-tile-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ transition: transform 0.3s ease;
+}
+
+.card-browser-tile:hover .card-browser-tile-image img {
+ transform: scale(1.05);
+}
+
+.card-browser-tile-info {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.card-browser-tile-name {
+ font-weight: 600;
+ font-size: 0.95rem;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+}
+
+.card-browser-tile-type {
+ font-size: 0.85rem;
+ color: var(--muted);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+}
+
+.card-browser-tile-stats {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.85rem;
+}
+
+.card-browser-tile-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-top: 0.25rem;
+}
+
+.card-browser-tile-tags .tag {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.4rem;
+ background: rgba(148, 163, 184, 0.15);
+ color: var(--muted);
+ border-radius: 3px;
+ white-space: nowrap;
+}
+
+/* Card Details button on tiles */
+.card-details-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--primary);
+ color: white;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: 500;
+ font-size: 0.85rem;
+ transition: all 0.2s;
+ margin-top: 0.5rem;
+ border: none;
+ cursor: pointer;
+}
+
+.card-details-btn:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
+}
+
+.card-details-btn svg {
+ flex-shrink: 0;
+}
+
+/* Card Preview Modal */
+.preview-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ z-index: 9999;
+ align-items: center;
+ justify-content: center;
+}
+
+.preview-modal.active {
+ display: flex;
+}
+
+.preview-content {
+ position: relative;
+ max-width: 90%;
+ max-height: 90%;
+}
+
+.preview-content img {
+ max-width: 100%;
+ max-height: 90vh;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+.preview-close {
+ position: absolute;
+ top: -40px;
+ right: 0;
+ background: rgba(255, 255, 255, 0.9);
+ color: #000;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ font-size: 24px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+}
+
+.preview-close:hover {
+ background: #fff;
+ transform: scale(1.1);
+}
+
+/* Pagination controls */
+.card-browser-pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 0;
+ flex-wrap: wrap;
+}
+
+.card-browser-pagination .btn {
+ min-width: 120px;
+}
+
+.card-browser-pagination .page-info {
+ font-size: 0.95rem;
+ color: var(--text);
+ padding: 0 1rem;
+}
+
+/* No results message */
+.no-results {
+ text-align: center;
+ padding: 3rem 1rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.no-results-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 0.5rem;
+}
+
+.no-results-message {
+ color: var(--muted);
+ margin-bottom: 1rem;
+ line-height: 1.5;
+}
+
+.no-results-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.no-results-filter-tag {
+ padding: 0.25rem 0.75rem;
+ background: rgba(148, 163, 184, 0.15);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ color: var(--text);
+}
+
+/* Loading indicator */
+.card-browser-loading {
+ text-align: center;
+ padding: 2rem;
+ color: var(--muted);
+}
+
+/* Responsive adjustments */
+/* Large tablets and below - reduce to ~180px cards */
+@media (max-width: 1024px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(200px, 200px));
+ }
+}
+
+/* Tablets - reduce to ~160px cards */
+@media (max-width: 768px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ }
+
+ .filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-row label {
+ min-width: auto;
+ }
+
+ .filter-row select,
+ .filter-row input {
+ max-width: 100%;
+ }
+
+ .card-browser-info {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+/* Small tablets/large phones - reduce to ~140px cards */
+@media (max-width: 600px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 160px));
+ gap: 0.5rem;
+ }
+}
+
+/* Phones - 2 column layout with flexible width */
+@media (max-width: 480px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.375rem;
+ }
+
+ .card-browser-tile-name {
+ font-size: 0.85rem;
+ }
+
+ .card-browser-tile-type {
+ font-size: 0.75rem;
+ }
+
+ .card-browser-tile-info {
+ padding: 0.5rem;
+ }
+}
+
+/* Theme chips for multi-select */
+.theme-chip {
+ display: inline-flex;
+ align-items: center;
+ background: var(--primary-bg);
+ color: var(--primary-fg);
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ border: 1px solid var(--border-color);
+}
+
+.theme-chip button {
+ margin-left: 0.5rem;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ font-weight: bold;
+ font-size: 1.2rem;
+ line-height: 1;
+}
+
+.theme-chip button:hover {
+ color: var(--error-color);
+}
+
+/* Card Detail Page Styles */
+.card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.card-tag {
+ background: var(--ring);
+ color: white;
+ padding: 0.35rem 0.75rem;
+ border-radius: 16px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.back-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--panel);
+ color: var(--text);
+ text-decoration: none;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ font-weight: 500;
+ transition: all 0.2s;
+ margin-bottom: 2rem;
+}
+
+.back-button:hover {
+ background: var(--ring);
+ color: white;
+ border-color: var(--ring);
+}
+
+/* Card Detail Page - Main Card Image */
+.card-image-large {
+ flex: 0 0 auto;
+ max-width: 360px !important;
+ width: 100%;
+}
+
+.card-image-large img {
+ width: 100%;
+ height: auto;
+ border-radius: 12px;
+}
diff --git a/code/web/static/shared-components.css b/code/web/static/shared-components.css
new file mode 100644
index 0000000..628dff0
--- /dev/null
+++ b/code/web/static/shared-components.css
@@ -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);
+}
diff --git a/code/web/static/styles.css b/code/web/static/styles.css
index eda7352..3cbc2f8 100644
--- a/code/web/static/styles.css
+++ b/code/web/static/styles.css
@@ -1,738 +1,2798 @@
+/* Tailwind CSS Entry Point */
+
+*, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+::backdrop {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(59 130 246 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+}
+
+/* ! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com */
+
+/*
+1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
+2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
+*/
+
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ /* 1 */
+ border-width: 0;
+ /* 2 */
+ border-style: solid;
+ /* 2 */
+ border-color: #e5e7eb;
+ /* 2 */
+}
+
+::before,
+::after {
+ --tw-content: '';
+}
+
+/*
+1. Use a consistent sensible line-height in all browsers.
+2. Prevent adjustments of font size after orientation changes in iOS.
+3. Use a more readable tab size.
+4. Use the user's configured `sans` font-family by default.
+5. Use the user's configured `sans` font-feature-settings by default.
+6. Use the user's configured `sans` font-variation-settings by default.
+7. Disable tap highlights on iOS
+*/
+
+html,
+:host {
+ line-height: 1.5;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+ -moz-tab-size: 4;
+ /* 3 */
+ -o-tab-size: 4;
+ tab-size: 4;
+ /* 3 */
+ font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ /* 4 */
+ font-feature-settings: normal;
+ /* 5 */
+ font-variation-settings: normal;
+ /* 6 */
+ -webkit-tap-highlight-color: transparent;
+ /* 7 */
+}
+
+/*
+1. Remove the margin in all browsers.
+2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
+*/
+
+body {
+ margin: 0;
+ /* 1 */
+ line-height: inherit;
+ /* 2 */
+}
+
+/*
+1. Add the correct height in Firefox.
+2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
+3. Ensure horizontal rules are visible by default.
+*/
+
+hr {
+ height: 0;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ border-top-width: 1px;
+ /* 3 */
+}
+
+/*
+Add the correct text decoration in Chrome, Edge, and Safari.
+*/
+
+abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+
+/*
+Remove the default font size and weight for headings.
+*/
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+/*
+Reset links to optimize for opt-in styling instead of opt-out.
+*/
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+/*
+Add the correct font weight in Edge and Safari.
+*/
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/*
+1. Use the user's configured `mono` font-family by default.
+2. Use the user's configured `mono` font-feature-settings by default.
+3. Use the user's configured `mono` font-variation-settings by default.
+4. Correct the odd `em` font sizing in all browsers.
+*/
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ /* 1 */
+ font-feature-settings: normal;
+ /* 2 */
+ font-variation-settings: normal;
+ /* 3 */
+ font-size: 1em;
+ /* 4 */
+}
+
+/*
+Add the correct font size in all browsers.
+*/
+
+small {
+ font-size: 80%;
+}
+
+/*
+Prevent `sub` and `sup` elements from affecting the line height in all browsers.
+*/
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/*
+1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
+2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
+3. Remove gaps between table borders by default.
+*/
+
+table {
+ text-indent: 0;
+ /* 1 */
+ border-color: inherit;
+ /* 2 */
+ border-collapse: collapse;
+ /* 3 */
+}
+
+/*
+1. Change the font styles in all browsers.
+2. Remove the margin in Firefox and Safari.
+3. Remove default padding in all browsers.
+*/
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-feature-settings: inherit;
+ /* 1 */
+ font-variation-settings: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ font-weight: inherit;
+ /* 1 */
+ line-height: inherit;
+ /* 1 */
+ letter-spacing: inherit;
+ /* 1 */
+ color: inherit;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+ padding: 0;
+ /* 3 */
+}
+
+/*
+Remove the inheritance of text transform in Edge and Firefox.
+*/
+
+button,
+select {
+ text-transform: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Remove default button styles.
+*/
+
+button,
+input:where([type='button']),
+input:where([type='reset']),
+input:where([type='submit']) {
+ -webkit-appearance: button;
+ /* 1 */
+ background-color: transparent;
+ /* 2 */
+ background-image: none;
+ /* 2 */
+}
+
+/*
+Use the modern Firefox focus style for all focusable elements.
+*/
+
+:-moz-focusring {
+ outline: auto;
+}
+
+/*
+Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
+*/
+
+:-moz-ui-invalid {
+ box-shadow: none;
+}
+
+/*
+Add the correct vertical alignment in Chrome and Firefox.
+*/
+
+progress {
+ vertical-align: baseline;
+}
+
+/*
+Correct the cursor style of increment and decrement buttons in Safari.
+*/
+
+::-webkit-inner-spin-button,
+::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/*
+1. Correct the odd appearance in Chrome and Safari.
+2. Correct the outline style in Safari.
+*/
+
+[type='search'] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/*
+Remove the inner padding in Chrome and Safari on macOS.
+*/
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/*
+1. Correct the inability to style clickable types in iOS and Safari.
+2. Change font properties to `inherit` in Safari.
+*/
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/*
+Add the correct display in Chrome and Safari.
+*/
+
+summary {
+ display: list-item;
+}
+
+/*
+Removes the default spacing and border for appropriate elements.
+*/
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+legend {
+ padding: 0;
+}
+
+ol,
+ul,
+menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+Reset default styling for dialogs.
+*/
+
+dialog {
+ padding: 0;
+}
+
+/*
+Prevent resizing textareas horizontally by default.
+*/
+
+textarea {
+ resize: vertical;
+}
+
+/*
+1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
+2. Set the default placeholder color to the user's configured gray 400 color.
+*/
+
+input::-moz-placeholder, textarea::-moz-placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ /* 1 */
+ color: #9ca3af;
+ /* 2 */
+}
+
+/*
+Set the default cursor for buttons.
+*/
+
+button,
+[role="button"] {
+ cursor: pointer;
+}
+
+/*
+Make sure disabled buttons don't get the pointer cursor.
+*/
+
+:disabled {
+ cursor: default;
+}
+
+/*
+1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
+2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
+ This can trigger a poorly considered lint error in some tools but is included by design.
+*/
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ /* 1 */
+ vertical-align: middle;
+ /* 2 */
+}
+
+/*
+Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
+*/
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Make elements with the HTML hidden attribute stay hidden by default */
+
+[hidden]:where(:not([hidden="until-found"])) {
+ display: none;
+}
+
+.\!container {
+ width: 100% !important;
+}
+
+.container {
+ width: 100%;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+.visible {
+ visibility: visible;
+}
+
+.collapse {
+ visibility: collapse;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.absolute {
+ position: absolute;
+}
+
+.relative {
+ position: relative;
+}
+
+.sticky {
+ position: sticky;
+}
+
+.m-0 {
+ margin: 0px;
+}
+
+.-my-1\.5 {
+ margin-top: -0.375rem;
+ margin-bottom: -0.375rem;
+}
+
+.my-1 {
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+}
+
+.my-1\.5 {
+ margin-top: 0.375rem;
+ margin-bottom: 0.375rem;
+}
+
+.my-2 {
+ margin-top: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.my-3\.5 {
+ margin-top: 0.875rem;
+ margin-bottom: 0.875rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.mb-1\.5 {
+ margin-bottom: 0.375rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.mb-3 {
+ margin-bottom: 0.75rem;
+}
+
+.mb-3\.5 {
+ margin-bottom: 0.875rem;
+}
+
+.mb-4 {
+ margin-bottom: 1rem;
+}
+
+.ml-1 {
+ margin-left: 0.25rem;
+}
+
+.ml-2 {
+ margin-left: 0.5rem;
+}
+
+.ml-6 {
+ margin-left: 1.5rem;
+}
+
+.ml-auto {
+ margin-left: auto;
+}
+
+.mr-2 {
+ margin-right: 0.5rem;
+}
+
+.mt-0 {
+ margin-top: 0px;
+}
+
+.mt-0\.5 {
+ margin-top: 0.125rem;
+}
+
+.mt-1 {
+ margin-top: 0.25rem;
+}
+
+.mt-1\.5 {
+ margin-top: 0.375rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.mt-3 {
+ margin-top: 0.75rem;
+}
+
+.mt-4 {
+ margin-top: 1rem;
+}
+
+.\!block {
+ display: block !important;
+}
+
+.block {
+ display: block;
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.inline {
+ display: inline;
+}
+
+.flex {
+ display: flex;
+}
+
+.inline-flex {
+ display: inline-flex;
+}
+
+.table {
+ display: table;
+}
+
+.\!grid {
+ display: grid !important;
+}
+
+.grid {
+ display: grid;
+}
+
+.hidden {
+ display: none;
+}
+
+.h-12 {
+ height: 3rem;
+}
+
+.h-auto {
+ height: auto;
+}
+
+.min-h-\[1\.1em\] {
+ min-height: 1.1em;
+}
+
+.min-h-\[1rem\] {
+ min-height: 1rem;
+}
+
+.w-24 {
+ width: 6rem;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.min-w-\[160px\] {
+ min-width: 160px;
+}
+
+.min-w-\[2\.5rem\] {
+ min-width: 2.5rem;
+}
+
+.min-w-\[220px\] {
+ min-width: 220px;
+}
+
+.max-w-\[230px\] {
+ max-width: 230px;
+}
+
+.flex-1 {
+ flex: 1 1 0%;
+}
+
+.flex-shrink {
+ flex-shrink: 1;
+}
+
+.grow {
+ flex-grow: 1;
+}
+
+.border-collapse {
+ border-collapse: collapse;
+}
+
+.transform {
+ transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.select-all {
+ -webkit-user-select: all;
+ -moz-user-select: all;
+ user-select: all;
+}
+
+.resize {
+ resize: both;
+}
+
+.list-none {
+ list-style-type: none;
+}
+
+.grid-cols-\[2fr_1fr\] {
+ grid-template-columns: 2fr 1fr;
+}
+
+.grid-cols-\[repeat\(auto-fill\2c minmax\(230px\2c 1fr\)\)\] {
+ grid-template-columns: repeat(auto-fill,minmax(230px,1fr));
+}
+
+.flex-row {
+ flex-direction: row;
+}
+
+.flex-col {
+ flex-direction: column;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.items-start {
+ align-items: flex-start;
+}
+
+.items-end {
+ align-items: flex-end;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-1 {
+ gap: 0.25rem;
+}
+
+.gap-1\.5 {
+ gap: 0.375rem;
+}
+
+.gap-2 {
+ gap: 0.5rem;
+}
+
+.gap-2\.5 {
+ gap: 0.625rem;
+}
+
+.gap-3 {
+ gap: 0.75rem;
+}
+
+.gap-3\.5 {
+ gap: 0.875rem;
+}
+
+.gap-4 {
+ gap: 1rem;
+}
+
+.overflow-hidden {
+ overflow: hidden;
+}
+
+.text-ellipsis {
+ text-overflow: ellipsis;
+}
+
+.whitespace-nowrap {
+ white-space: nowrap;
+}
+
+.rounded-\[10px\] {
+ border-radius: 10px;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.border {
+ border-width: 1px;
+}
+
+.border-0 {
+ border-width: 0px;
+}
+
+.border-\[var\(--border\)\] {
+ border-color: var(--border);
+}
+
+.bg-gray-700 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
+}
+
+.p-0 {
+ padding: 0px;
+}
+
+.p-2 {
+ padding: 0.5rem;
+}
+
+.px-1\.5 {
+ padding-left: 0.375rem;
+ padding-right: 0.375rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.py-0\.5 {
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-\[11px\] {
+ font-size: 11px;
+}
+
+.text-\[13px\] {
+ font-size: 13px;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-normal {
+ font-weight: 400;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.uppercase {
+ text-transform: uppercase;
+}
+
+.capitalize {
+ text-transform: capitalize;
+}
+
+.italic {
+ font-style: italic;
+}
+
+.text-\[var\(--text\)\] {
+ color: var(--text);
+}
+
+.text-gray-200 {
+ --tw-text-opacity: 1;
+ color: rgb(229 231 235 / var(--tw-text-opacity, 1));
+}
+
+.underline {
+ text-decoration-line: underline;
+}
+
+.no-underline {
+ text-decoration-line: none;
+}
+
+.opacity-30 {
+ opacity: 0.3;
+}
+
+.opacity-70 {
+ opacity: 0.7;
+}
+
+.opacity-85 {
+ opacity: 0.85;
+}
+
+.outline {
+ outline-style: solid;
+}
+
+.ring {
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+}
+
+.blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.filter {
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
+.transition {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.ease-in-out {
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.\[start\:end\] {
+ start: end;
+}
+
+/* Import custom CSS (not purged by Tailwind) */
+
/* Base */
+
:root{
- /* MTG color palette (approx from provided values) */
- --banner-h: 52px;
- --sidebar-w: 260px;
- --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);
- --bg: #0f0f10;
- --panel: #1a1b1e;
- --text: #e8e8e8;
- --muted: #b6b8bd;
- --border: #2a2b2f;
- --ring: #60a5fa; /* focus ring */
- --ok: #16a34a; /* success */
- --warn: #f59e0b; /* warning */
- --err: #ef4444; /* error */
- /* Surface overrides for specific regions (default to panel) */
- --surface-banner: var(--panel);
- --surface-banner-text: var(--text);
- --surface-sidebar: var(--panel);
- --surface-sidebar-text: var(--text);
+ /* MTG color palette (approx from provided values) */
+ --banner-h: 52px;
+ --sidebar-w: 260px;
+ --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);
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
+ --ring: #60a5fa;
+ /* focus ring */
+ --ok: #16a34a;
+ /* success */
+ --warn: #f59e0b;
+ /* warning */
+ --err: #ef4444;
+ /* error */
+ /* Surface overrides for specific regions (default to panel) */
+ --surface-banner: var(--panel);
+ --surface-banner-text: var(--text);
+ --surface-sidebar: var(--panel);
+ --surface-sidebar-text: var(--text);
}
/* Light blend between Slate and Parchment (leans gray) */
+
[data-theme="light-blend"]{
- --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
- --panel: #ffffff; /* crisp panels for readability */
- --text: #0b0d12;
- --muted: #6b655d; /* slightly warm muted */
- --border: #d6d1c7; /* neutral warm-gray border */
- /* Slightly darker banner/sidebar for separation */
- --surface-banner: #1a1b1e;
- --surface-sidebar: #1a1b1e;
- --surface-banner-text: #e8e8e8;
- --surface-sidebar-text: #e8e8e8;
+ --bg: #e8e2d0;
+ /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
+ --panel: #ffffff;
+ /* crisp panels for readability */
+ --text: #0b0d12;
+ --muted: #6b655d;
+ /* slightly warm muted */
+ --border: #d6d1c7;
+ /* neutral warm-gray border */
+ /* Slightly darker banner/sidebar for separation */
+ --surface-banner: #1a1b1e;
+ --surface-sidebar: #1a1b1e;
+ --surface-banner-text: #e8e8e8;
+ --surface-sidebar-text: #e8e8e8;
}
[data-theme="dark"]{
- --bg: #0f0f10;
- --panel: #1a1b1e;
- --text: #e8e8e8;
- --muted: #b6b8bd;
- --border: #2a2b2f;
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
}
+
[data-theme="high-contrast"]{
- --bg: #000;
- --panel: #000;
- --text: #fff;
- --muted: #e5e7eb;
- --border: #fff;
- --ring: #ff0;
+ --bg: #000;
+ --panel: #000;
+ --text: #fff;
+ --muted: #e5e7eb;
+ --border: #fff;
+ --ring: #ff0;
}
+
[data-theme="cb-friendly"]{
- /* Tweak accents for color-blind friendliness */
- --green-main: #2e7d32; /* darker green */
- --red-main: #c62828; /* deeper red */
- --blue-main: #1565c0; /* balanced blue */
+ /* Tweak accents for color-blind friendliness */
+ --green-main: #2e7d32;
+ /* darker green */
+ --red-main: #c62828;
+ /* deeper red */
+ --blue-main: #1565c0;
+ /* balanced blue */
}
-*{box-sizing:border-box}
-html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;}
+
+*{
+ box-sizing:border-box
+}
+
+html{
+ height:100%;
+ overflow-x:hidden;
+ overflow-y:scroll;
+ max-width:100vw;
+}
+
body {
- font-family: system-ui, Arial, sans-serif;
- margin: 0;
- color: var(--text);
- background: var(--bg);
- display: flex;
- flex-direction: column;
- height: 100%;
- width: 100%;
- overflow-x: hidden;
- overflow-y: auto;
+ font-family: system-ui, Arial, sans-serif;
+ margin: 0;
+ color: var(--text);
+ background: var(--bg);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
}
+
/* Honor HTML hidden attribute across the app */
-[hidden] { display: none !important; }
-/* Accessible focus ring for keyboard navigation */
-.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
-/* Top banner */
-.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
-.top-banner{ min-height: var(--banner-h); }
-.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; }
-.top-banner .top-inner > div{ min-width:0; }
-@media (max-width: 1100px){
- .top-banner .top-inner{ grid-auto-rows:auto; }
- .top-banner .top-inner select{ max-width:140px; }
+
+[hidden] {
+ display: none !important;
+}
+
+/* Accessible focus ring for keyboard navigation */
+
+.focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+/* Top banner - simplified, no changes on sidebar toggle */
+
+.top-banner{
+ position:sticky;
+ top:0;
+ z-index:10;
+ background: var(--surface-banner);
+ color: var(--surface-banner-text);
+ border-bottom:1px solid var(--border);
+ box-shadow:0 2px 6px rgba(0,0,0,.4);
+ min-height: var(--banner-h);
+}
+
+.top-banner .top-inner{
+ margin:0;
+ padding:.4rem 15px;
+ display:flex;
+ align-items:center;
+ width:100%;
+ box-sizing:border-box;
+}
+
+.top-banner h1{
+ font-size: 1.1rem;
+ margin:0;
+ margin-left: 25px;
+}
+
+.flex-row{
+ display: flex;
+ align-items: center;
+ gap: 25px;
+}
+
+.top-banner .banner-left{
+ width: 260px !important;
+ flex-shrink: 0 !important;
+}
+
+/* Hide elements on all screen sizes */
+
+#btn-open-permalink{
+ display:none !important;
+}
+
+#banner-status{
+ display:none !important;
+}
+
+.top-banner #theme-reset{
+ display:none !important;
}
-.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
-.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
-.banner-status.busy{ color:#fbbf24; }
-.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
-.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
/* Layout */
-.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
-.sidebar{
- background: var(--surface-sidebar);
- color: var(--surface-sidebar-text);
- border-right: 1px solid var(--border);
- padding: 1rem;
- position: fixed;
- top: var(--banner-h);
- left: 0;
- bottom: 0;
- overflow: auto;
- width: var(--sidebar-w);
- z-index: 9; /* below the banner (z=10) */
- box-shadow: 2px 0 10px rgba(0,0,0,.18);
- display: flex;
- flex-direction: column;
+
+.layout{
+ display:grid;
+ grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
+ flex: 1 0 auto;
+}
+
+.sidebar{
+ background: var(--surface-sidebar);
+ color: var(--surface-sidebar-text);
+ border-right: 1px solid var(--border);
+ padding: 1rem;
+ position: fixed;
+ top: var(--banner-h);
+ left: 0;
+ bottom: 0;
+ overflow: auto;
+ width: var(--sidebar-w);
+ z-index: 9;
+ /* below the banner (z=10) */
+ box-shadow: 2px 0 10px rgba(0,0,0,.18);
+ display: flex;
+ flex-direction: column;
+}
+
+.content{
+ padding: 1.25rem 1.5rem;
+ grid-column: 2;
+ min-width: 0;
}
-.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
/* Collapsible sidebar behavior */
-body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); }
-body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; }
-body.nav-collapsed .content{ grid-column: 2; }
-body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; }
-body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; }
+
+body.nav-collapsed .layout{
+ grid-template-columns: 0 minmax(0, 1fr);
+}
+
+body.nav-collapsed .sidebar{
+ transform: translateX(-100%);
+ visibility: hidden;
+}
+
+body.nav-collapsed .content{
+ grid-column: 2;
+}
+
+/* Sidebar collapsed state doesn't change banner grid on desktop anymore */
+
/* Smooth hide/show on mobile while keeping fixed positioning */
-.sidebar{ transition: transform .2s ease-out, visibility .2s linear; }
+
+.sidebar{
+ transition: transform .2s ease-out, visibility .2s linear;
+ overflow-x: hidden;
+}
+
/* Suppress sidebar transitions during page load to prevent pop-in */
-body.no-transition .sidebar{ transition: none !important; }
+
+body.no-transition .sidebar{
+ transition: none !important;
+}
+
/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */
-body.htmx-settling .sidebar{ transition: none !important; }
-body.htmx-settling .layout{ transition: none !important; }
-body.htmx-settling .content{ transition: none !important; }
-body.htmx-settling *{ transition-duration: 0s !important; }
+
+body.htmx-settling .sidebar{
+ transition: none !important;
+}
+
+body.htmx-settling .layout{
+ transition: none !important;
+}
+
+body.htmx-settling .content{
+ transition: none !important;
+}
+
+body.htmx-settling *{
+ transition-duration: 0s !important;
+}
/* Mobile tweaks */
+
@media (max-width: 900px){
- :root{ --sidebar-w: 240px; }
- .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; }
- .banner-status{ padding-left: .5rem; }
- .layout{ grid-template-columns: 0 1fr; }
- .sidebar{ transform: translateX(-100%); visibility: hidden; }
- body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
- body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
- .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
- .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); }
- /* Spacing tweaks: tighter left, larger gaps between visible items */
- .top-banner .top-inner > div{ gap: 25px !important; }
- .top-banner .top-inner > div:first-child{ padding-left: 0 !important; }
- /* Mobile: show only Menu, Title, and Theme selector */
- #btn-open-permalink{ display:none !important; }
- #banner-status{ display:none !important; }
- #health-dot{ display:none !important; }
- .top-banner #theme-reset{ display:none !important; }
+ :root{
+ --sidebar-w: 240px;
+ }
+
+ .layout{
+ grid-template-columns: 0 1fr;
+ }
+
+ .sidebar{
+ transform: translateX(-100%);
+ visibility: hidden;
+ }
+
+ body:not(.nav-collapsed) .layout{
+ grid-template-columns: var(--sidebar-w) 1fr;
+ }
+
+ body:not(.nav-collapsed) .sidebar{
+ transform: translateX(0);
+ visibility: visible;
+ }
+
+ .content{
+ padding: .9rem .6rem;
+ max-width: 100vw;
+ box-sizing: border-box;
+ overflow-x: hidden;
+ }
}
/* Additional mobile spacing for bottom floating controls */
+
@media (max-width: 720px) {
- .content {
- padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */
- }
+ .content {
+ padding-bottom: 6rem !important;
+ /* Extra bottom padding to account for floating controls */
+ }
}
-.brand h1{ display:none; }
-.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
-.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
-.dot.green{ background: var(--green-main); }
-.dot.blue{ background: var(--blue-main); }
-.dot.red{ background: var(--red-main); }
-.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
-.dot.black{ background: var(--black-light); }
+.brand h1{
+ display:none;
+}
-.nav{ display:flex; flex-direction:column; gap:.35rem; }
-.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
-.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
+.brand{
+ padding-top: 0;
+ margin-top: 0;
+}
+
+.mana-dots{
+ display:flex;
+ gap:.35rem;
+ margin-bottom:.5rem;
+ margin-top: 0;
+ padding-top: 0;
+}
+
+.mana-dots .dot{
+ width:12px;
+ height:12px;
+ border-radius:50%;
+ display:inline-block;
+ border:1px solid rgba(0,0,0,.35);
+ box-shadow:0 1px 2px rgba(0,0,0,.3) inset;
+}
+
+.dot.green{
+ background: var(--green-main);
+}
+
+.dot.blue{
+ background: var(--blue-main);
+}
+
+.dot.red{
+ background: var(--red-main);
+}
+
+.dot.white{
+ background: var(--white-light);
+ border-color: rgba(0,0,0,.2);
+}
+
+.dot.black{
+ background: var(--black-light);
+}
+
+.nav{
+ display:flex;
+ flex-direction:column;
+ gap:.35rem;
+}
+
+.nav a{
+ color: var(--surface-sidebar-text);
+ text-decoration:none;
+ padding:.4rem .5rem;
+ border-radius:6px;
+ border:1px solid transparent;
+}
+
+.nav a:hover{
+ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%);
+ border-color: var(--border);
+}
/* Sidebar theme controls anchored at bottom */
-.sidebar .nav { flex: 1 1 auto; }
-.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
-.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; }
-.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; }
-.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; }
-.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); }
+
+.sidebar .nav {
+ flex: 1 1 auto;
+}
+
+.sidebar-theme {
+ margin-top: auto;
+ padding-top: .75rem;
+ border-top: 1px solid var(--border);
+}
+
+.sidebar-theme-label {
+ display:block;
+ color: var(--surface-sidebar-text);
+ font-size: 12px;
+ opacity:.8;
+ margin: 0 0 .35rem .1rem;
+}
+
+.sidebar-theme-row {
+ display:flex;
+ align-items:center;
+ gap:.5rem;
+ flex-wrap: nowrap;
+}
+
+.sidebar-theme-row select {
+ background: var(--panel);
+ color: var(--text);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.3rem .4rem;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.sidebar-theme-row .btn-ghost {
+ background: transparent;
+ color: var(--surface-sidebar-text);
+ border:1px solid var(--border);
+ flex-shrink: 0;
+ white-space: nowrap;
+}
/* Simple two-column layout for inspect panel */
-.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
-.two-col .grow { min-width: 0; }
-.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); }
-@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
+
+.two-col {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 1rem;
+ align-items: start;
+}
+
+.two-col .grow {
+ min-width: 0;
+}
+
+.card-preview img {
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ box-shadow: 0 6px 18px rgba(0,0,0,.35);
+ border:1px solid var(--border);
+ background: var(--panel);
+}
+
+@media (max-width: 900px) {
+ .two-col {
+ grid-template-columns: 1fr;
+ }
+}
/* Left-rail variant puts the image first */
-.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
-/* Ensure left-rail variant also collapses to 1 column on small screens */
-@media (max-width: 900px){
- .two-col.two-col-left-rail{ grid-template-columns: 1fr; }
- /* So the commander image doesn't dominate on mobile */
- .two-col .card-preview{ max-width: 360px; margin: 0 auto; }
- .two-col .card-preview img{ width: 100%; height: auto; }
+
+.two-col.two-col-left-rail{
+ grid-template-columns: 320px 1fr;
+}
+
+/* Ensure left-rail variant also collapses to 1 column on small screens */
+
+@media (max-width: 900px){
+ .two-col.two-col-left-rail{
+ grid-template-columns: 1fr;
+ }
+
+ /* So the commander image doesn't dominate on mobile */
+
+ .two-col .card-preview{
+ max-width: 360px;
+ margin: 0 auto;
+ }
+
+ .two-col .card-preview img{
+ width: 100%;
+ height: auto;
+ }
+}
+
+.card-preview.card-sm{
+ max-width:200px;
}
-.card-preview.card-sm{ max-width:200px; }
/* Buttons, inputs */
-button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
-button:hover{ filter:brightness(1.05); }
+
+button{
+ background: var(--blue-main);
+ color:#fff;
+ border:none;
+ border-radius:6px;
+ padding:.45rem .7rem;
+ cursor:pointer;
+}
+
+button:hover{
+ filter:brightness(1.05);
+}
+
/* Anchor-style buttons */
-.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
-.btn:hover{ filter:brightness(1.05); text-decoration:none; }
-.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
-label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
-.color-identity{ display:inline-flex; align-items:center; gap:.35rem; }
-.color-identity .mana + .mana{ margin-left:4px; }
-.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; }
-.mana-W{ background:#f9fafb; border-color:#d1d5db; }
-.mana-U{ background:#3b82f6; border-color:#1d4ed8; }
-.mana-B{ background:#111827; border-color:#1f2937; }
-.mana-R{ background:#ef4444; border-color:#b91c1c; }
-.mana-G{ background:#10b981; border-color:#047857; }
-.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
-select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
-fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
-small, .muted{ color: var(--muted); }
-.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
-.partner-preview[hidden]{ display:none !important; }
-.partner-preview__header{ font-weight:600; }
-.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; }
-.partner-preview__art{ flex:0 0 auto; }
-.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); }
-.partner-preview__details{ flex:1 1 180px; min-width:0; }
-.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }
-.partner-preview__pairing{ margin-top:.35rem; }
-.partner-preview__themes{ margin-top:.35rem; font-size:12px; }
-.partner-preview--static{ margin-bottom:.5rem; }
-.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); }
+
+.btn{
+ display:inline-block;
+ background: var(--blue-main);
+ color:#fff;
+ border:none;
+ border-radius:6px;
+ padding:.45rem .7rem;
+ cursor:pointer;
+ text-decoration:none;
+ line-height:1;
+}
+
+.btn:hover{
+ filter:brightness(1.05);
+ text-decoration:none;
+}
+
+.btn.disabled, .btn[aria-disabled="true"]{
+ opacity:.6;
+ cursor:default;
+ pointer-events:none;
+}
+
+label{
+ display:inline-flex;
+ flex-direction:column;
+ gap:.25rem;
+ margin-right:.75rem;
+}
+
+.color-identity{
+ display:inline-flex;
+ align-items:center;
+ gap:.35rem;
+}
+
+.color-identity .mana + .mana{
+ margin-left:4px;
+}
+
+.mana{
+ display:inline-block;
+ width:16px;
+ height:16px;
+ border-radius:50%;
+ border:1px solid var(--border);
+ box-shadow:0 0 0 1px rgba(0,0,0,.25) inset;
+}
+
+.mana-W{
+ background:#f9fafb;
+ border-color:#d1d5db;
+}
+
+.mana-U{
+ background:#3b82f6;
+ border-color:#1d4ed8;
+}
+
+.mana-B{
+ background:#111827;
+ border-color:#1f2937;
+}
+
+.mana-R{
+ background:#ef4444;
+ border-color:#b91c1c;
+}
+
+.mana-G{
+ background:#10b981;
+ border-color:#047857;
+}
+
+.mana-C{
+ background:#d3d3d3;
+ border-color:#9ca3af;
+}
+
+select,input[type="text"],input[type="number"]{
+ background: var(--panel);
+ color:var(--text);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.35rem .4rem;
+}
+
+fieldset{
+ border:1px solid var(--border);
+ border-radius:8px;
+ padding:.75rem;
+ margin:.75rem 0;
+}
+
+small, .muted{
+ color: var(--muted);
+}
+
+.partner-preview{
+ border:1px solid var(--border);
+ border-radius:8px;
+ background: var(--panel);
+ padding:.75rem;
+ margin-bottom:.5rem;
+}
+
+.partner-preview[hidden]{
+ display:none !important;
+}
+
+.partner-preview__header{
+ font-weight:600;
+}
+
+.partner-preview__layout{
+ display:flex;
+ gap:.75rem;
+ align-items:flex-start;
+ flex-wrap:wrap;
+}
+
+.partner-preview__art{
+ flex:0 0 auto;
+}
+
+.partner-preview__art img{
+ width:140px;
+ max-width:100%;
+ border-radius:6px;
+ box-shadow:0 4px 12px rgba(0,0,0,.35);
+}
+
+.partner-preview__details{
+ flex:1 1 180px;
+ min-width:0;
+}
+
+.partner-preview__role{
+ margin-top:.2rem;
+ font-size:12px;
+ color:var(--muted);
+ letter-spacing:.04em;
+ text-transform:uppercase;
+}
+
+.partner-preview__pairing{
+ margin-top:.35rem;
+}
+
+.partner-preview__themes{
+ margin-top:.35rem;
+ font-size:12px;
+}
+
+.partner-preview--static{
+ margin-bottom:.5rem;
+}
+
+.partner-card-preview img{
+ box-shadow:0 4px 12px rgba(0,0,0,.3);
+}
/* Toasts */
-.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
-.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
-.toast.hide{ opacity:0; transform: translateY(6px); }
-.toast.success{ border-color: rgba(22,163,74,.4); }
-.toast.error{ border-color: rgba(239,68,68,.45); }
-.toast.warn{ border-color: rgba(245,158,11,.45); }
+
+.toast-host{
+ position: fixed;
+ right: 12px;
+ bottom: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 9999;
+}
+
+.toast{
+ background: rgba(17,24,39,.95);
+ color:#e5e7eb;
+ border:1px solid var(--border);
+ border-radius:10px;
+ padding:.5rem .65rem;
+ box-shadow: 0 8px 24px rgba(0,0,0,.35);
+ transition: transform .2s ease, opacity .2s ease;
+}
+
+.toast.hide{
+ opacity:0;
+ transform: translateY(6px);
+}
+
+.toast.success{
+ border-color: rgba(22,163,74,.4);
+}
+
+.toast.error{
+ border-color: rgba(239,68,68,.45);
+}
+
+.toast.warn{
+ border-color: rgba(245,158,11,.45);
+}
/* Skeletons */
-[data-skeleton]{ position: relative; }
-[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; }
-[data-skeleton-placeholder]{ display:none; pointer-events:none; }
-[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; }
+
+[data-skeleton]{
+ position: relative;
+}
+
+[data-skeleton].is-loading > :not([data-skeleton-placeholder]){
+ opacity: 0;
+}
+
+[data-skeleton-placeholder]{
+ display:none;
+ pointer-events:none;
+}
+
+[data-skeleton].is-loading > [data-skeleton-placeholder]{
+ display:flex;
+ flex-direction:column;
+ opacity:1;
+}
+
[data-skeleton][data-skeleton-overlay="false"]::after,
-[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; }
+[data-skeleton][data-skeleton-overlay="false"]::before{
+ display:none !important;
+}
+
[data-skeleton]::after{
- content: '';
- position: absolute; inset: 0;
- border-radius: 8px;
- background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
- background-size: 200% 100%;
- animation: shimmer 1.1s linear infinite;
- display: none;
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: 8px;
+ background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
+ background-size: 200% 100%;
+ animation: shimmer 1.1s linear infinite;
+ display: none;
}
-[data-skeleton].is-loading::after{ display:block; }
+
+[data-skeleton].is-loading::after{
+ display:block;
+}
+
[data-skeleton].is-loading::before{
- content: attr(data-skeleton-label);
- position:absolute;
- top:50%;
- left:50%;
- transform:translate(-50%, -50%);
- color: var(--muted);
- font-size:.85rem;
- text-align:center;
- line-height:1.4;
- max-width:min(92%, 360px);
- padding:.3rem .5rem;
- pointer-events:none;
- z-index:1;
- filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
+ content: attr(data-skeleton-label);
+ position:absolute;
+ top:50%;
+ left:50%;
+ transform:translate(-50%, -50%);
+ color: var(--muted);
+ font-size:.85rem;
+ text-align:center;
+ line-height:1.4;
+ max-width:min(92%, 360px);
+ padding:.3rem .5rem;
+ pointer-events:none;
+ z-index:1;
+ filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
+}
+
+[data-skeleton][data-skeleton-label=""]::before{
+ content:'';
+}
+
+@keyframes shimmer{
+ 0%{
+ background-position: 200% 0;
+ }
+
+ 100%{
+ background-position: -200% 0;
+ }
}
-[data-skeleton][data-skeleton-label=""]::before{ content:''; }
-@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
-.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
-.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
-.banner .subtitle{ color: var(--muted); font-size:.95rem; }
+
+.banner{
+ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0));
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 2rem 1.6rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 8px 30px rgba(0,0,0,.25) inset;
+}
+
+.banner h1{
+ font-size: 2rem;
+ margin:0 0 .35rem;
+}
+
+.banner .subtitle{
+ color: var(--muted);
+ font-size:.95rem;
+}
/* Home actions */
-.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
-.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
-.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); }
-.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
+
+.actions-grid{
+ display:grid;
+ grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) );
+ gap: .75rem;
+}
+
+.action-button{
+ display:block;
+ text-decoration:none;
+ color: var(--text);
+ border:1px solid var(--border);
+ background: var(--panel);
+ padding:1.25rem;
+ border-radius:10px;
+ text-align:center;
+ font-weight:600;
+}
+
+.action-button:hover{
+ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%);
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+}
+
+.action-button.primary{
+ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05));
+ border-color: #274766;
+}
+
+/* Home page darker buttons */
+
+.home-button.btn-secondary {
+ background: #1a1d24;
+ border-color: #2a2d35;
+}
+
+.home-button.btn-secondary:hover {
+ background: #22252d;
+ border-color: #3a3d45;
+}
+
+.home-button.btn-primary {
+ background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
+ border-color: #2a5580;
+}
+
+.home-button.btn-primary:hover {
+ background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
+ border-color: #3a6590;
+}
/* Card grid for added cards (responsive, compact tiles) */
+
.card-grid{
- display:grid;
- grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
- gap: .5rem;
- margin-top:.5rem;
- justify-content: start; /* pack as many as possible per row */
- /* Prevent scroll chaining bounce that can cause flicker near bottom */
- overscroll-behavior: contain;
- content-visibility: auto;
- contain: layout paint;
- contain-intrinsic-size: 640px 420px;
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 170px));
+ /* ~160px image + padding */
+ gap: .5rem;
+ margin-top:.5rem;
+ justify-content: start;
+ /* pack as many as possible per row */
+ /* Prevent scroll chaining bounce that can cause flicker near bottom */
+ overscroll-behavior: contain;
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 640px 420px;
}
+
@media (max-width: 420px){
- .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .card-tile{ width: 100%; }
- .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; }
+ .card-grid{
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .card-tile{
+ width: 100%;
+ }
+
+ .card-tile img{
+ width: 100%;
+ max-width: 160px;
+ margin: 0 auto;
+ }
}
+
.card-tile{
- width:170px;
- position: relative;
- background: var(--panel);
- border:1px solid var(--border);
- border-radius:6px;
- padding:.25rem .25rem .4rem;
- text-align:center;
+ width:170px;
+ position: relative;
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.25rem .25rem .4rem;
+ text-align:center;
}
-.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
+
+.card-tile.game-changer{
+ border-color: var(--red-main);
+ box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset;
+}
+
.card-tile.locked{
- /* Subtle yellow/goldish-white accent for locked cards */
- border-color: #f5e6a8; /* soft parchment gold */
- box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
+ /* Subtle yellow/goldish-white accent for locked cards */
+ border-color: #f5e6a8;
+ /* soft parchment gold */
+ box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
}
+
.card-tile.must-include{
- border-color: rgba(74,222,128,.85);
- box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
+ border-color: rgba(74,222,128,.85);
+ box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
}
+
.card-tile.must-exclude{
- border-color: rgba(239,68,68,.85);
- box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
- opacity: .95;
+ border-color: rgba(239,68,68,.85);
+ box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
+ opacity: .95;
}
+
.card-tile.must-include.must-exclude{
- border-color: rgba(249,115,22,.85);
- box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset;
+ border-color: rgba(249,115,22,.85);
+ box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset;
+}
+
+.card-tile img{
+ width:160px;
+ height:auto;
+ border-radius:6px;
+ box-shadow: 0 6px 18px rgba(0,0,0,.35);
+ background:#111;
+}
+
+.card-tile .name{
+ font-weight:600;
+ margin-top:.25rem;
+ font-size:.92rem;
+}
+
+.card-tile .reason{
+ color:var(--muted);
+ font-size:.85rem;
+ margin-top:.15rem;
}
-.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
-.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
-.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
.must-have-controls{
- display:flex;
- justify-content:center;
- gap:.35rem;
- flex-wrap:wrap;
- margin-top:.35rem;
+ display:flex;
+ justify-content:center;
+ gap:.35rem;
+ flex-wrap:wrap;
+ margin-top:.35rem;
}
+
.must-have-btn{
- border:1px solid var(--border);
- background:rgba(30,41,59,.6);
- color:#f8fafc;
- font-size:11px;
- text-transform:uppercase;
- letter-spacing:.06em;
- padding:.25rem .6rem;
- border-radius:9999px;
- cursor:pointer;
- transition: all .18s ease;
+ border:1px solid var(--border);
+ background:rgba(30,41,59,.6);
+ color:#f8fafc;
+ font-size:11px;
+ text-transform:uppercase;
+ letter-spacing:.06em;
+ padding:.25rem .6rem;
+ border-radius:9999px;
+ cursor:pointer;
+ transition: all .18s ease;
}
+
.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{
- border-color: rgba(74,222,128,.75);
- background: rgba(74,222,128,.18);
- color: #bbf7d0;
- box-shadow: 0 0 0 1px rgba(16,185,129,.25);
+ border-color: rgba(74,222,128,.75);
+ background: rgba(74,222,128,.18);
+ color: #bbf7d0;
+ box-shadow: 0 0 0 1px rgba(16,185,129,.25);
}
+
.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{
- border-color: rgba(239,68,68,.75);
- background: rgba(239,68,68,.18);
- color: #fecaca;
- box-shadow: 0 0 0 1px rgba(239,68,68,.25);
+ border-color: rgba(239,68,68,.75);
+ background: rgba(239,68,68,.18);
+ color: #fecaca;
+ box-shadow: 0 0 0 1px rgba(239,68,68,.25);
}
+
.must-have-btn:focus-visible{
- outline:2px solid rgba(59,130,246,.6);
- outline-offset:2px;
+ outline:2px solid rgba(59,130,246,.6);
+ outline-offset:2px;
}
+
.card-tile.must-exclude .must-have-btn.include[data-active="0"],
.card-tile.must-include .must-have-btn.exclude[data-active="0"]{
- opacity:.65;
+ opacity:.65;
}
-.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
-.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
+.group-grid{
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 540px 360px;
+}
+
+.alt-list{
+ list-style:none;
+ padding:0;
+ margin:0;
+ display:grid;
+ gap:.25rem;
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 320px 220px;
+}
+
+.alt-option{
+ display:block !important;
+ width:100%;
+ max-width:100%;
+ text-align:left;
+ 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;
+}
/* Shared ownership badge for card tiles and stacked images */
+
.owned-badge{
- position:absolute;
- top:6px;
- left:6px;
- background:rgba(17,24,39,.9);
- color:#e5e7eb;
- border:1px solid var(--border);
- border-radius:12px;
- font-size:12px;
- line-height:18px;
- height:18px;
- min-width:18px;
- padding:0 6px;
- text-align:center;
- pointer-events:none;
- z-index:2;
+ position:absolute;
+ top:6px;
+ left:6px;
+ background:rgba(17,24,39,.9);
+ color:#e5e7eb;
+ border:1px solid var(--border);
+ border-radius:12px;
+ font-size:12px;
+ line-height:18px;
+ height:18px;
+ min-width:18px;
+ padding:0 6px;
+ text-align:center;
+ pointer-events:none;
+ z-index:2;
}
/* Step 1 candidate grid (200px-wide scaled images) */
+
.candidate-grid{
- display:grid;
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
- gap:.75rem;
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap:.75rem;
}
+
.candidate-tile{
- background: var(--panel);
- border:1px solid var(--border);
- border-radius:8px;
- padding:.4rem;
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:8px;
+ padding:.4rem;
+}
+
+.candidate-tile .img-btn{
+ display:block;
+ width:100%;
+ padding:0;
+ background:transparent;
+ border:none;
+ cursor:pointer;
+}
+
+.candidate-tile img{
+ width:100%;
+ max-width:200px;
+ height:auto;
+ border-radius:8px;
+ box-shadow:0 6px 18px rgba(0,0,0,.35);
+ background: var(--panel);
+ display:block;
+ margin:0 auto;
+}
+
+.candidate-tile .meta{
+ text-align:center;
+ margin-top:.35rem;
+}
+
+.candidate-tile .name{
+ font-weight:600;
+ font-size:.95rem;
+}
+
+.candidate-tile .score{
+ color:var(--muted);
+ font-size:.85rem;
}
-.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
-.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; }
-.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
-.candidate-tile .name{ font-weight:600; font-size:.95rem; }
-.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
/* Deck summary: highlight game changers */
-.game-changer { color: var(--green-main); }
-.stack-card.game-changer { outline: 2px solid var(--green-main); }
+
+.game-changer {
+ color: var(--green-main);
+}
+
+.stack-card.game-changer {
+ outline: 2px solid var(--green-main);
+}
/* Image button inside card tiles */
-.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
+
+.card-tile .img-btn{
+ display:block;
+ padding:0;
+ background:transparent;
+ border:none;
+ cursor:pointer;
+ width:100%;
+}
/* Stage Navigator */
-.stage-nav { margin:.5rem 0 1rem; }
-.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
-.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
-.stage-nav .stage-item.done .stage-link { opacity:.75; }
-.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
-.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
-.stage-nav .name { font-size:12px; }
+
+.stage-nav {
+ margin:.5rem 0 1rem;
+}
+
+.stage-nav ol {
+ list-style:none;
+ padding:0;
+ margin:0;
+ display:flex;
+ gap:.35rem;
+ flex-wrap:wrap;
+}
+
+.stage-nav .stage-link {
+ display:flex;
+ align-items:center;
+ gap:.4rem;
+ background: var(--panel);
+ border:1px solid var(--border);
+ color:var(--text);
+ border-radius:999px;
+ padding:.25rem .6rem;
+ cursor:pointer;
+}
+
+.stage-nav .stage-item.done .stage-link {
+ opacity:.75;
+}
+
+.stage-nav .stage-item.current .stage-link {
+ box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset;
+ border-color:#3b82f6;
+}
+
+.stage-nav .idx {
+ display:inline-grid;
+ place-items:center;
+ width:20px;
+ height:20px;
+ border-radius:50%;
+ background:#1f2937;
+ font-size:12px;
+}
+
+.stage-nav .name {
+ font-size:12px;
+}
/* Build controls sticky box tweaks */
-.build-controls {
- position: sticky;
- top: calc(var(--banner-offset, 48px) + 6px);
- z-index: 100;
- background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
- backdrop-filter: blur(8px);
- border: 1px solid var(--border);
- border-radius: 10px;
- margin: 0.5rem 0;
- box-shadow: 0 4px 12px rgba(0,0,0,.25);
+
+.build-controls {
+ position: sticky;
+ top: calc(var(--banner-offset, 48px) + 6px);
+ z-index: 100;
+ background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin: 0.5rem 0;
+ box-shadow: 0 4px 12px rgba(0,0,0,.25);
}
@media (max-width: 1024px){
- :root { --banner-offset: 56px; }
- .build-controls {
- position: fixed !important; /* Fixed to viewport instead of sticky */
- bottom: 0 !important; /* Anchor to bottom of screen */
- left: 0 !important;
- right: 0 !important;
- top: auto !important; /* Override top positioning */
- border-radius: 0 !important; /* Remove border radius for full width */
- margin: 0 !important; /* Remove margins for full edge-to-edge */
- padding: 0.5rem !important; /* Reduced padding */
- box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */
- border-left: none !important;
- border-right: none !important;
- border-bottom: none !important; /* Remove bottom border */
- background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
- z-index: 1000 !important; /* Higher z-index to ensure it's above content */
- }
+ :root {
+ --banner-offset: 56px;
+ }
+
+ .build-controls {
+ position: fixed !important;
+ /* Fixed to viewport instead of sticky */
+ bottom: 0 !important;
+ /* Anchor to bottom of screen */
+ left: 0 !important;
+ right: 0 !important;
+ top: auto !important;
+ /* Override top positioning */
+ border-radius: 0 !important;
+ /* Remove border radius for full width */
+ margin: 0 !important;
+ /* Remove margins for full edge-to-edge */
+ padding: 0.5rem !important;
+ /* Reduced padding */
+ box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important;
+ /* Upward shadow */
+ border-left: none !important;
+ border-right: none !important;
+ border-bottom: none !important;
+ /* Remove bottom border */
+ background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
+ z-index: 1000 !important;
+ /* Higher z-index to ensure it's above content */
+ }
}
+
@media (min-width: 721px){
- :root { --banner-offset: 48px; }
+ :root {
+ --banner-offset: 48px;
+ }
}
/* Progress bar */
-.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
-.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
-.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
+
+.progress {
+ position: relative;
+ height: 10px;
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress .bar {
+ position:absolute;
+ left:0;
+ top:0;
+ bottom:0;
+ width: 0%;
+ background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9));
+}
+
+.progress.flash {
+ box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset;
+}
/* Chips */
-.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
-.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
+
+.chip {
+ display:inline-flex;
+ align-items:center;
+ gap:.35rem;
+ background: var(--panel);
+ border:1px solid var(--border);
+ color:var(--text);
+ border-radius:999px;
+ padding:.2rem .55rem;
+ font-size:12px;
+}
+
+.chip .dot {
+ width:8px;
+ height:8px;
+ border-radius:50%;
+ background:#6b7280;
+}
+
+.chip:hover {
+ background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%);
+ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%);
+}
+
+.chip.active {
+ background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15));
+ border-color: #3b82f6;
+ color: #60a5fa;
+ font-weight: 600;
+ box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset;
+}
+
+.chip.active:hover {
+ background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25));
+ border-color: #60a5fa;
+}
/* Cards toolbar */
-.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
-.cards-toolbar input[type="text"]{ min-width: 220px; }
-.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
-.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
+
+.cards-toolbar{
+ display:flex;
+ flex-wrap:wrap;
+ gap:.5rem .75rem;
+ align-items:center;
+ margin:.5rem 0 .25rem;
+}
+
+.cards-toolbar input[type="text"]{
+ min-width: 220px;
+}
+
+.cards-toolbar .sep{
+ width:1px;
+ height:20px;
+ background: var(--border);
+ margin:0 .25rem;
+}
+
+.cards-toolbar .hint{
+ color: var(--muted);
+ font-size:12px;
+}
/* Collapse groups and reason toggle */
-.group{ margin:.5rem 0; }
-.group-header{ display:flex; align-items:center; gap:.5rem; }
-.group-header h5{ margin:.4rem 0; }
-.group-header .count{ color: var(--muted); font-size:12px; }
-.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
-.group-grid[data-collapsed]{ display:none; }
-.hide-reasons .card-tile .reason{ display:none; }
-.card-tile.force-show .reason{ display:block !important; }
-.card-tile.force-hide .reason{ display:none !important; }
-.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
-.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
-.chips-inline .chip{ cursor:pointer; user-select:none; }
+
+.group{
+ margin:.5rem 0;
+}
+
+.group-header{
+ display:flex;
+ align-items:center;
+ gap:.5rem;
+}
+
+.group-header h5{
+ margin:.4rem 0;
+}
+
+.group-header .count{
+ color: var(--muted);
+ font-size:12px;
+}
+
+.group-header .toggle{
+ margin-left:auto;
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+ color: var(--text);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.2rem .5rem;
+ font-size:12px;
+ cursor:pointer;
+}
+
+.group-grid[data-collapsed]{
+ display:none;
+}
+
+.hide-reasons .card-tile .reason{
+ display:none;
+}
+
+.card-tile.force-show .reason{
+ display:block !important;
+}
+
+.card-tile.force-hide .reason{
+ display:none !important;
+}
+
+.btn-why{
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+ color: var(--text);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.15rem .4rem;
+ font-size:12px;
+ cursor:pointer;
+}
+
+.chips-inline{
+ display:flex;
+ gap:.35rem;
+ flex-wrap:wrap;
+ align-items:center;
+}
+
+.chips-inline .chip{
+ cursor:pointer;
+ -webkit-user-select:none;
+ -moz-user-select:none;
+ user-select:none;
+}
/* Inline error banner */
-.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
-.inline-error-banner .muted{ color:#fda4af; }
+
+.inline-error-banner{
+ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%);
+ border:1px solid #b91c1c;
+ color:#b91c1c;
+ padding:.5rem .6rem;
+ border-radius:8px;
+ margin-bottom:.5rem;
+}
+
+.inline-error-banner .muted{
+ color:#fda4af;
+}
/* Alternatives panel */
-.alts ul{ list-style:none; padding:0; margin:0; }
-.alts li{ display:flex; align-items:center; gap:.4rem; }
+
+.alts ul{
+ list-style:none;
+ padding:0;
+ margin:0;
+}
+
+.alts li{
+ display:flex;
+ align-items:center;
+ gap:.4rem;
+}
+
/* LQIP blur/fade-in for thumbnails */
-img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
-img.lqip.loaded { filter: blur(0); opacity: 1; }
+
+img.lqip {
+ filter: blur(8px);
+ opacity: .6;
+ transition: filter .25s ease-out, opacity .25s ease-out;
+}
+
+img.lqip.loaded {
+ filter: blur(0);
+ opacity: 1;
+}
/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
+
@media (prefers-reduced-motion: reduce) {
- * { scroll-behavior: auto !important; }
- img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
+ * {
+ scroll-behavior: auto !important;
+ }
+
+ img.lqip {
+ transition: none !important;
+ filter: none !important;
+ opacity: 1 !important;
+ }
}
/* Virtualization wrapper should mirror grid to keep multi-column flow */
-.virt-wrapper { display: grid; }
+
+.virt-wrapper {
+ display: grid;
+}
/* Mobile responsive fixes for horizontal scrolling issues */
+
@media (max-width: 768px) {
- /* Prevent horizontal overflow */
- html, body {
- overflow-x: hidden !important;
- width: 100% !important;
- max-width: 100vw !important;
- }
+ /* Prevent horizontal overflow */
- /* Test hand responsive adjustments */
- #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; }
+ html, body {
+ overflow-x: hidden !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ }
- /* Modal & form layout fixes (original block retained inside media query) */
- /* Fix modal layout on mobile */
- .modal {
- padding: 10px !important;
- box-sizing: border-box;
- }
- .modal-content {
- width: 100% !important;
- max-width: calc(100vw - 20px) !important;
- box-sizing: border-box !important;
- overflow-x: hidden !important;
- }
- /* Force single column for include/exclude grid */
- .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; }
- /* Fix basics grid */
- .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; }
- /* Ensure all inputs and textareas fit properly */
- .modal input,
+ /* Test hand responsive adjustments */
+
+ #test-hand{
+ --card-w: 170px !important;
+ --card-h: 238px !important;
+ --overlap: .5 !important;
+ }
+
+ /* Modal & form layout fixes (original block retained inside media query) */
+
+ /* Fix modal layout on mobile */
+
+ .modal {
+ padding: 10px !important;
+ box-sizing: border-box;
+ }
+
+ .modal-content {
+ width: 100% !important;
+ max-width: calc(100vw - 20px) !important;
+ box-sizing: border-box !important;
+ overflow-x: hidden !important;
+ }
+
+ /* Force single column for include/exclude grid */
+
+ .include-exclude-grid {
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 1rem !important;
+ }
+
+ /* Fix basics grid */
+
+ .basics-grid {
+ grid-template-columns: 1fr !important;
+ gap: 1rem !important;
+ }
+
+ /* Ensure all inputs and textareas fit properly */
+
+ .modal input,
.modal textarea,
- .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; }
- /* Fix chips containers */
- .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; }
- /* Ensure fieldsets don't overflow */
- .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; }
- /* Fix any inline styles that might cause overflow */
- .modal fieldset > div,
- .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; }
+ .modal select {
+ width: 100% !important;
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ min-width: 0 !important;
+ }
+
+ /* Fix chips containers */
+
+ .modal [id$="_chips_container"] {
+ max-width: 100% !important;
+ overflow-x: hidden !important;
+ word-wrap: break-word !important;
+ }
+
+ /* Ensure fieldsets don't overflow */
+
+ .modal fieldset {
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ overflow-x: hidden !important;
+ }
+
+ /* Fix any inline styles that might cause overflow */
+
+ .modal fieldset > div,
+ .modal fieldset > div > div {
+ max-width: 100% !important;
+ overflow-x: hidden !important;
+ }
}
@media (max-width: 640px){
- #test-hand{ --card-w: 150px !important; --card-h: 210px !important; }
- /* Generic stack shrink */
- .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; }
+ #test-hand{
+ --card-w: 150px !important;
+ --card-h: 210px !important;
+ }
+
+ /* Generic stack shrink */
+
+ .stack-wrap:not(#test-hand){
+ --card-w: 150px;
+ --card-h: 210px;
+ }
}
@media (max-width: 560px){
- #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; }
- #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; }
- #test-hand .stack-card{ flex:0 0 auto; }
- .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; }
+ #test-hand{
+ --card-w: 140px !important;
+ --card-h: 196px !important;
+ padding-bottom:.75rem;
+ }
+
+ #test-hand .stack-grid{
+ display:flex !important;
+ gap:.5rem;
+ grid-template-columns:none !important;
+ overflow-x:auto;
+ padding-bottom:.25rem;
+ }
+
+ #test-hand .stack-card{
+ flex:0 0 auto;
+ }
+
+ .stack-wrap:not(#test-hand){
+ --card-w: 140px;
+ --card-h: 196px;
+ }
}
@media (max-width: 480px) {
- .modal-content {
- padding: 12px !important;
- margin: 5px !important;
- }
-
- .modal fieldset {
- padding: 8px !important;
- margin: 6px 0 !important;
- }
-
- /* Enhanced mobile build controls */
- .build-controls {
- flex-direction: column !important;
- gap: 0.25rem !important; /* Reduced gap */
- align-items: stretch !important;
- padding: 0.5rem !important; /* Reduced padding */
- }
-
- /* Two-column grid layout for mobile build controls */
- .build-controls {
- display: grid !important;
- grid-template-columns: 1fr 1fr !important; /* Two equal columns */
- grid-gap: 0.25rem !important;
- align-items: stretch !important;
- }
-
- .build-controls form {
- display: contents !important; /* Allow form contents to participate in grid */
- width: auto !important;
- }
-
- .build-controls button {
- flex: none !important;
- padding: 0.4rem 0.5rem !important; /* Much smaller padding */
- font-size: 12px !important; /* Smaller font */
- min-height: 36px !important; /* Smaller minimum height */
- line-height: 1.2 !important;
- width: 100% !important; /* Full width within grid cell */
- box-sizing: border-box !important;
- white-space: nowrap !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- }
-
- /* Hide non-essential elements on mobile to keep it clean */
- .build-controls .sep,
+ .modal-content {
+ padding: 12px !important;
+ margin: 5px !important;
+ }
+
+ .modal fieldset {
+ padding: 8px !important;
+ margin: 6px 0 !important;
+ }
+
+ /* Enhanced mobile build controls */
+
+ .build-controls {
+ flex-direction: column !important;
+ gap: 0.25rem !important;
+ /* Reduced gap */
+ align-items: stretch !important;
+ padding: 0.5rem !important;
+ /* Reduced padding */
+ }
+
+ /* Two-column grid layout for mobile build controls */
+
+ .build-controls {
+ display: grid !important;
+ grid-template-columns: 1fr 1fr !important;
+ /* Two equal columns */
+ grid-gap: 0.25rem !important;
+ align-items: stretch !important;
+ }
+
+ .build-controls form {
+ display: contents !important;
+ /* Allow form contents to participate in grid */
+ width: auto !important;
+ }
+
+ .build-controls button {
+ flex: none !important;
+ padding: 0.4rem 0.5rem !important;
+ /* Much smaller padding */
+ font-size: 12px !important;
+ /* Smaller font */
+ min-height: 36px !important;
+ /* Smaller minimum height */
+ line-height: 1.2 !important;
+ width: 100% !important;
+ /* Full width within grid cell */
+ box-sizing: border-box !important;
+ white-space: nowrap !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ /* Hide non-essential elements on mobile to keep it clean */
+
+ .build-controls .sep,
.build-controls .replace-toggle,
.build-controls label[style*="margin-left"] {
- display: none !important;
- }
-
- .build-controls .sep {
- display: none !important; /* Hide separators on mobile */
- }
+ display: none !important;
+ }
+
+ .build-controls .sep {
+ display: none !important;
+ /* Hide separators on mobile */
+ }
}
/* Desktop sizing for Test Hand */
+
@media (min-width: 900px) {
- #test-hand { --card-w: 280px !important; --card-h: 392px !important; }
+ #test-hand {
+ --card-w: 280px !important;
+ --card-h: 392px !important;
+ }
}
/* Analytics accordion styling */
+
.analytics-accordion {
- transition: all 0.2s ease;
+ transition: all 0.2s ease;
}
.analytics-accordion summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- transition: background-color 0.15s ease, border-color 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
}
.analytics-accordion summary:hover {
- background: #1f2937;
- border-color: #374151;
+ background: #1f2937;
+ border-color: #374151;
}
.analytics-accordion summary:active {
- transform: scale(0.99);
+ transform: scale(0.99);
}
.analytics-accordion[open] summary {
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
- margin-bottom: 0;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ margin-bottom: 0;
}
.analytics-accordion .analytics-content {
- animation: accordion-slide-down 0.3s ease-out;
+ animation: accordion-slide-down 0.3s ease-out;
}
@keyframes accordion-slide-down {
- from {
- opacity: 0;
- transform: translateY(-8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
.analytics-placeholder .skeleton-pulse {
- animation: shimmer 1.5s infinite;
+ animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
- 0% { background-position: -200% 0; }
- 100% { background-position: 200% 0; }
+ 0% {
+ background-position: -200% 0;
+ }
+
+ 100% {
+ background-position: 200% 0;
+ }
}
/* Ideals Slider Styling */
+
.ideals-slider {
- -webkit-appearance: none;
- appearance: none;
- height: 6px;
- background: var(--border);
- border-radius: 3px;
- outline: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ height: 6px;
+ background: var(--border);
+ border-radius: 3px;
+ outline: none;
}
.ideals-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 18px;
- height: 18px;
- background: var(--ring);
- border-radius: 50%;
- cursor: pointer;
- transition: all 0.15s ease;
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border-radius: 50%;
+ cursor: pointer;
+ -webkit-transition: all 0.15s ease;
+ transition: all 0.15s ease;
}
.ideals-slider::-webkit-slider-thumb:hover {
- transform: scale(1.15);
- box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
}
.ideals-slider::-moz-range-thumb {
- width: 18px;
- height: 18px;
- background: var(--ring);
- border: none;
- border-radius: 50%;
- cursor: pointer;
- transition: all 0.15s ease;
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ -moz-transition: all 0.15s ease;
+ transition: all 0.15s ease;
}
.ideals-slider::-moz-range-thumb:hover {
- transform: scale(1.15);
- box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
}
.slider-value {
- display: inline-block;
- padding: 0.25rem 0.5rem;
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 4px;
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
}
/* ========================================
@@ -740,469 +2800,2840 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
======================================== */
/* Card browser container */
+
.card-browser-container {
- display: flex;
- flex-direction: column;
- gap: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
}
/* Filter panel */
+
.card-browser-filters {
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 1rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
}
.filter-section {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
}
.filter-row {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
}
.filter-row label {
- font-weight: 600;
- min-width: 80px;
- color: var(--text);
- font-size: 0.95rem;
+ font-weight: 600;
+ min-width: 80px;
+ color: var(--text);
+ font-size: 0.95rem;
}
.filter-row select,
.filter-row input[type="text"],
.filter-row input[type="search"] {
- flex: 1;
- min-width: 150px;
- max-width: 300px;
+ flex: 1;
+ min-width: 150px;
+ max-width: 300px;
}
/* Search bar styling */
+
.card-search-wrapper {
- position: relative;
- flex: 1;
- max-width: 100%;
+ position: relative;
+ flex: 1;
+ max-width: 100%;
}
.card-search-wrapper input[type="search"] {
- width: 100%;
- padding: 0.5rem 0.75rem;
- font-size: 1rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 1rem;
}
/* Results count and info bar */
+
.card-browser-info {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.5rem;
- padding: 0.5rem 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
}
.results-count {
- font-size: 0.95rem;
- color: var(--muted);
+ font-size: 0.95rem;
+ color: var(--muted);
}
.page-indicator {
- font-size: 0.95rem;
- color: var(--text);
- font-weight: 600;
+ font-size: 0.95rem;
+ color: var(--text);
+ font-weight: 600;
}
/* Card browser grid */
+
.card-browser-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(240px, 240px));
- gap: 0.5rem;
- padding: 0.5rem;
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 8px;
- min-height: 480px;
- justify-content: start;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 240px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-height: 480px;
+ justify-content: start;
}
/* Individual card tile in browser */
+
.card-browser-tile {
- break-inside: avoid;
- display: flex;
- flex-direction: column;
- background: var(--card-bg, #1a1d24);
- border: 1px solid var(--border);
- border-radius: 8px;
- overflow: hidden;
- transition: transform 0.2s ease, box-shadow 0.2s ease;
- cursor: pointer;
+ -moz-column-break-inside: avoid;
+ break-inside: avoid;
+ display: flex;
+ flex-direction: column;
+ background: var(--card-bg, #1a1d24);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ cursor: pointer;
}
.card-browser-tile:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%);
}
.card-browser-tile-image {
- position: relative;
- width: 100%;
- aspect-ratio: 488/680;
- overflow: hidden;
- background: #0a0b0e;
+ position: relative;
+ width: 100%;
+ aspect-ratio: 488/680;
+ overflow: hidden;
+ background: #0a0b0e;
}
.card-browser-tile-image img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- transition: transform 0.3s ease;
+ width: 100%;
+ height: 100%;
+ -o-object-fit: contain;
+ object-fit: contain;
+ transition: transform 0.3s ease;
}
.card-browser-tile:hover .card-browser-tile-image img {
- transform: scale(1.05);
+ transform: scale(1.05);
}
.card-browser-tile-info {
- padding: 0.75rem;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
}
.card-browser-tile-name {
- font-weight: 600;
- font-size: 0.95rem;
- word-wrap: break-word;
- overflow-wrap: break-word;
- line-height: 1.3;
+ font-weight: 600;
+ font-size: 0.95rem;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
}
.card-browser-tile-type {
- font-size: 0.85rem;
- color: var(--muted);
- word-wrap: break-word;
- overflow-wrap: break-word;
- line-height: 1.3;
+ font-size: 0.85rem;
+ color: var(--muted);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
}
.card-browser-tile-stats {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 0.85rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.85rem;
}
.card-browser-tile-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 0.25rem;
- margin-top: 0.25rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-top: 0.25rem;
}
.card-browser-tile-tags .tag {
- font-size: 0.7rem;
- padding: 0.15rem 0.4rem;
- background: rgba(148, 163, 184, 0.15);
- color: var(--muted);
- border-radius: 3px;
- white-space: nowrap;
+ font-size: 0.7rem;
+ padding: 0.15rem 0.4rem;
+ background: rgba(148, 163, 184, 0.15);
+ color: var(--muted);
+ border-radius: 3px;
+ white-space: nowrap;
}
/* Card Details button on tiles */
+
.card-details-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.35rem;
- padding: 0.5rem 0.75rem;
- background: var(--primary);
- color: white;
- text-decoration: none;
- border-radius: 6px;
- font-weight: 500;
- font-size: 0.85rem;
- transition: all 0.2s;
- margin-top: 0.5rem;
- border: none;
- cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--primary);
+ color: white;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: 500;
+ font-size: 0.85rem;
+ transition: all 0.2s;
+ margin-top: 0.5rem;
+ border: none;
+ cursor: pointer;
}
.card-details-btn:hover {
- background: var(--primary-hover);
- transform: translateY(-1px);
- box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
.card-details-btn svg {
- flex-shrink: 0;
+ flex-shrink: 0;
}
/* Card Preview Modal */
+
.preview-modal {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.85);
- z-index: 9999;
- align-items: center;
- justify-content: center;
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ z-index: 9999;
+ align-items: center;
+ justify-content: center;
}
.preview-modal.active {
- display: flex;
+ display: flex;
}
.preview-content {
- position: relative;
- max-width: 90%;
- max-height: 90%;
+ position: relative;
+ max-width: 90%;
+ max-height: 90%;
}
.preview-content img {
- max-width: 100%;
- max-height: 90vh;
- border-radius: 12px;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ max-width: 100%;
+ max-height: 90vh;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.preview-close {
- position: absolute;
- top: -40px;
- right: 0;
- background: rgba(255, 255, 255, 0.9);
- color: #000;
- border: none;
- border-radius: 50%;
- width: 36px;
- height: 36px;
- font-size: 24px;
- font-weight: bold;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
+ position: absolute;
+ top: -40px;
+ right: 0;
+ background: rgba(255, 255, 255, 0.9);
+ color: #000;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ font-size: 24px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
}
.preview-close:hover {
- background: #fff;
- transform: scale(1.1);
+ background: #fff;
+ transform: scale(1.1);
}
/* Pagination controls */
+
.card-browser-pagination {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 1rem;
- padding: 1rem 0;
- flex-wrap: wrap;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 0;
+ flex-wrap: wrap;
}
.card-browser-pagination .btn {
- min-width: 120px;
+ min-width: 120px;
}
.card-browser-pagination .page-info {
- font-size: 0.95rem;
- color: var(--text);
- padding: 0 1rem;
+ font-size: 0.95rem;
+ color: var(--text);
+ padding: 0 1rem;
}
/* No results message */
+
.no-results {
- text-align: center;
- padding: 3rem 1rem;
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 8px;
+ text-align: center;
+ padding: 3rem 1rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
}
.no-results-title {
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--text);
- margin-bottom: 0.5rem;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 0.5rem;
}
.no-results-message {
- color: var(--muted);
- margin-bottom: 1rem;
- line-height: 1.5;
+ color: var(--muted);
+ margin-bottom: 1rem;
+ line-height: 1.5;
}
.no-results-filters {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- justify-content: center;
- margin-bottom: 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+ margin-bottom: 1rem;
}
.no-results-filter-tag {
- padding: 0.25rem 0.75rem;
- background: rgba(148, 163, 184, 0.15);
- border: 1px solid var(--border);
- border-radius: 6px;
- font-size: 0.9rem;
- color: var(--text);
+ padding: 0.25rem 0.75rem;
+ background: rgba(148, 163, 184, 0.15);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ color: var(--text);
}
/* Loading indicator */
+
.card-browser-loading {
- text-align: center;
- padding: 2rem;
- color: var(--muted);
+ text-align: center;
+ padding: 2rem;
+ color: var(--muted);
}
/* Responsive adjustments */
+
/* Large tablets and below - reduce to ~180px cards */
+
@media (max-width: 1024px) {
- .card-browser-grid {
- grid-template-columns: repeat(auto-fill, minmax(200px, 200px));
- }
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(200px, 200px));
+ }
}
/* Tablets - reduce to ~160px cards */
+
@media (max-width: 768px) {
- .card-browser-grid {
- grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
- gap: 0.5rem;
- padding: 0.5rem;
- }
-
- .filter-row {
- flex-direction: column;
- align-items: stretch;
- }
-
- .filter-row label {
- min-width: auto;
- }
-
- .filter-row select,
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ }
+
+ .filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-row label {
+ min-width: auto;
+ }
+
+ .filter-row select,
.filter-row input {
- max-width: 100%;
- }
-
- .card-browser-info {
- flex-direction: column;
- align-items: flex-start;
- }
+ max-width: 100%;
+ }
+
+ .card-browser-info {
+ flex-direction: column;
+ align-items: flex-start;
+ }
}
/* Small tablets/large phones - reduce to ~140px cards */
+
@media (max-width: 600px) {
- .card-browser-grid {
- grid-template-columns: repeat(auto-fill, minmax(160px, 160px));
- gap: 0.5rem;
- }
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 160px));
+ gap: 0.5rem;
+ }
}
/* Phones - 2 column layout with flexible width */
+
@media (max-width: 480px) {
- .card-browser-grid {
- grid-template-columns: repeat(2, 1fr);
- gap: 0.375rem;
- }
-
- .card-browser-tile-name {
- font-size: 0.85rem;
- }
-
- .card-browser-tile-type {
- font-size: 0.75rem;
- }
-
- .card-browser-tile-info {
- padding: 0.5rem;
- }
+ .card-browser-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.375rem;
+ }
+
+ .card-browser-tile-name {
+ font-size: 0.85rem;
+ }
+
+ .card-browser-tile-type {
+ font-size: 0.75rem;
+ }
+
+ .card-browser-tile-info {
+ padding: 0.5rem;
+ }
}
/* Theme chips for multi-select */
+
.theme-chip {
- display: inline-flex;
- align-items: center;
- background: var(--primary-bg);
- color: var(--primary-fg);
- padding: 0.25rem 0.75rem;
- border-radius: 1rem;
- font-size: 0.9rem;
- border: 1px solid var(--border-color);
+ display: inline-flex;
+ align-items: center;
+ background: var(--primary-bg);
+ color: var(--primary-fg);
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ border: 1px solid var(--border-color);
}
.theme-chip button {
- margin-left: 0.5rem;
- background: none;
- border: none;
- color: inherit;
- cursor: pointer;
- padding: 0;
- font-weight: bold;
- font-size: 1.2rem;
- line-height: 1;
+ margin-left: 0.5rem;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ font-weight: bold;
+ font-size: 1.2rem;
+ line-height: 1;
}
.theme-chip button:hover {
- color: var(--error-color);
+ color: var(--error-color);
}
/* Card Detail Page Styles */
+
.card-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 1rem;
- margin-bottom: 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
}
.card-tag {
- background: var(--ring);
- color: white;
- padding: 0.35rem 0.75rem;
- border-radius: 16px;
- font-size: 0.85rem;
- font-weight: 500;
+ background: var(--ring);
+ color: white;
+ padding: 0.35rem 0.75rem;
+ border-radius: 16px;
+ font-size: 0.85rem;
+ font-weight: 500;
}
.back-button {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1.5rem;
- background: var(--panel);
- color: var(--text);
- text-decoration: none;
- border-radius: 8px;
- border: 1px solid var(--border);
- font-weight: 500;
- transition: all 0.2s;
- margin-bottom: 2rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--panel);
+ color: var(--text);
+ text-decoration: none;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ font-weight: 500;
+ transition: all 0.2s;
+ margin-bottom: 2rem;
}
.back-button:hover {
- background: var(--ring);
- color: white;
- border-color: var(--ring);
+ background: var(--ring);
+ color: white;
+ border-color: var(--ring);
}
/* Card Detail Page - Main Card Image */
+
.card-image-large {
- flex: 0 0 auto;
- max-width: 360px !important;
- width: 100%;
+ flex: 0 0 auto;
+ max-width: 360px !important;
+ width: 100%;
}
.card-image-large img {
- width: 100%;
- height: auto;
- border-radius: 12px;
+ width: 100%;
+ height: auto;
+ border-radius: 12px;
}
+
+/* ============================================
+ M2 Component Library Styles
+ ============================================ */
+
+/* === BUTTONS === */
+
+/* Button Base - enhanced from existing .btn */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ background: var(--blue-main);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ text-decoration: none;
+ line-height: 1.5;
+ font-weight: 500;
+ transition: filter 0.15s ease, transform 0.05s ease;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ filter: brightness(1.1);
+ text-decoration: none;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn:disabled,
+.btn.disabled,
+.btn[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* Button Variants */
+
+.btn-primary {
+ background: var(--blue-main);
+ color: #fff;
+}
+
+.btn-secondary {
+ background: var(--muted);
+ color: var(--text);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-ghost:hover {
+ background: var(--panel);
+ border-color: var(--text);
+}
+
+.btn-danger {
+ background: var(--err);
+ color: #fff;
+}
+
+/* Button Sizes */
+
+.btn-sm {
+ padding: 0.25rem 0.75rem;
+ font-size: 0.875rem;
+}
+
+.btn-md {
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+}
+
+.btn-lg {
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+/* Icon Button */
+
+.btn-icon {
+ padding: 0.5rem;
+ aspect-ratio: 1;
+ justify-content: center;
+}
+
+.btn-icon.btn-sm {
+ padding: 0.25rem;
+ font-size: 1rem;
+}
+
+/* Close Button */
+
+.btn-close {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ font-size: 1.5rem;
+ line-height: 1;
+ z-index: 10;
+}
+
+/* Tag/Chip Button */
+
+.btn-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 0.25rem 0.75rem;
+ font-size: 0.875rem;
+ transition: all 0.15s ease;
+}
+
+.btn-tag:hover {
+ background: var(--border);
+ border-color: var(--text);
+}
+
+.btn-tag-selected {
+ background: var(--blue-main);
+ color: #fff;
+ border-color: var(--blue-main);
+}
+
+.btn-tag-remove {
+ background: transparent;
+ border: none;
+ color: inherit;
+ padding: 0;
+ margin: 0;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0.7;
+}
+
+.btn-tag-remove:hover {
+ opacity: 1;
+}
+
+/* Button Group */
+
+.btn-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.btn-group-left {
+ justify-content: flex-start;
+}
+
+.btn-group-center {
+ justify-content: center;
+}
+
+.btn-group-right {
+ justify-content: flex-end;
+}
+
+.btn-group-between {
+ justify-content: space-between;
+}
+
+/* Legacy action-btn compatibility */
+
+.action-btn {
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+/* === MODALS === */
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(2px);
+ z-index: -1;
+}
+
+.modal-content {
+ position: relative;
+ background: #0f1115;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ padding: 1rem;
+ width: 100%;
+ max-height: min(92vh, 100%);
+ display: flex;
+ flex-direction: column;
+}
+
+/* Modal Sizes */
+
+.modal-sm .modal-content {
+ max-width: 480px;
+}
+
+.modal-md .modal-content {
+ max-width: 620px;
+}
+
+.modal-lg .modal-content {
+ max-width: 720px;
+}
+
+.modal-xl .modal-content {
+ max-width: 960px;
+}
+
+/* Modal Position */
+
+.modal-center {
+ align-items: center;
+}
+
+.modal-top {
+ align-items: flex-start;
+ padding-top: 2rem;
+}
+
+/* Modal Scrollable */
+
+.modal-scrollable .modal-content {
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* Modal Structure */
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding-right: 2rem;
+}
+
+.modal-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.modal-body {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.modal-footer {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Modal Variants */
+
+.modal-confirm .modal-body {
+ padding: 1rem 0;
+ font-size: 0.95rem;
+}
+
+.modal-alert {
+ text-align: center;
+}
+
+.modal-alert .modal-body {
+ padding: 1.5rem 0;
+}
+
+.modal-alert .alert-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+}
+
+.modal-alert-info .alert-icon::before {
+ content: 'ℹ️';
+}
+
+.modal-alert-success .alert-icon::before {
+ content: '✅';
+}
+
+.modal-alert-warning .alert-icon::before {
+ content: '⚠️';
+}
+
+.modal-alert-error .alert-icon::before {
+ content: '❌';
+}
+
+/* === FORMS === */
+
+.form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.form-label {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.form-required {
+ color: var(--err);
+ font-weight: bold;
+}
+
+.form-input-wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.form-input,
+.form-textarea,
+.form-select {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ width: 100%;
+}
+
+.form-input:focus,
+.form-textarea:focus,
+.form-select:focus {
+ outline: none;
+ border-color: var(--ring);
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+.form-input:disabled,
+.form-textarea:disabled,
+.form-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.form-textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+.form-input-number {
+ max-width: 150px;
+}
+
+.form-input-file {
+ padding: 0.375rem 0.5rem;
+}
+
+/* Checkbox and Radio */
+
+.form-field-checkbox,
+.form-field-radio {
+ flex-direction: row;
+ align-items: flex-start;
+}
+
+.form-checkbox-label,
+.form-radio-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-weight: normal;
+}
+
+.form-checkbox,
+.form-radio {
+ width: 1.125rem;
+ height: 1.125rem;
+ border: 1px solid var(--border);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.form-checkbox {
+ border-radius: 4px;
+}
+
+.form-radio {
+ border-radius: 50%;
+}
+
+.form-checkbox:checked,
+.form-radio:checked {
+ background: var(--blue-main);
+ border-color: var(--blue-main);
+}
+
+.form-checkbox:focus,
+.form-radio:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+.form-radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Form Help and Error Text */
+
+.form-help-text {
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-top: -0.25rem;
+}
+
+.form-error-text {
+ font-size: 0.8rem;
+ color: var(--err);
+ margin-top: -0.25rem;
+}
+
+.form-field-error .form-input,
+.form-field-error .form-textarea,
+.form-field-error .form-select {
+ border-color: var(--err);
+}
+
+/* === CARD DISPLAY COMPONENTS === */
+
+/* Card Thumbnail Container */
+
+.card-thumb-container {
+ position: relative;
+ display: inline-block;
+}
+
+.card-thumb {
+ display: block;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: #0b0d12;
+ -o-object-fit: cover;
+ object-fit: cover;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.card-thumb:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* Card Thumbnail Sizes */
+
+.card-thumb-small .card-thumb {
+ width: 160px;
+ height: auto;
+}
+
+.card-thumb-medium .card-thumb {
+ width: 230px;
+ height: auto;
+}
+
+.card-thumb-large .card-thumb {
+ width: 360px;
+ height: auto;
+}
+
+/* Card Flip Button */
+
+.card-flip-btn {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ padding: 0.375rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(4px);
+ transition: background 0.15s ease;
+ z-index: 5;
+}
+
+.card-flip-btn:hover {
+ background: rgba(0, 0, 0, 0.9);
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.card-flip-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+/* Card Name Label */
+
+.card-name-label {
+ font-size: 0.75rem;
+ margin-top: 0.375rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 600;
+ text-align: center;
+}
+
+/* Card Hover Popup */
+
+.card-popup {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.card-popup-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(2px);
+ z-index: -1;
+}
+
+.card-popup-content {
+ position: relative;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ padding: 1rem;
+ max-width: 400px;
+ width: 100%;
+}
+
+.card-popup-image {
+ position: relative;
+ margin-bottom: 1rem;
+}
+
+.card-popup-image img {
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+
+.card-popup-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.card-popup-name {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.card-popup-role {
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+
+.card-popup-role span {
+ color: var(--text);
+ font-weight: 500;
+}
+
+.card-popup-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.card-popup-tag {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 0.25rem 0.5rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+}
+
+.card-popup-tag-highlight {
+ background: var(--blue-main);
+ color: #fff;
+ border-color: var(--blue-main);
+}
+
+.card-popup-close {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ width: 2rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ line-height: 1;
+ cursor: pointer;
+ backdrop-filter: blur(4px);
+}
+
+.card-popup-close:hover {
+ background: rgba(0, 0, 0, 0.9);
+}
+
+/* Card Grid */
+
+.card-grid {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+}
+
+.card-grid-cols-auto {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+}
+
+.card-grid-cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.card-grid-cols-3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.card-grid-cols-4 {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.card-grid-cols-5 {
+ grid-template-columns: repeat(5, 1fr);
+}
+
+.card-grid-cols-6 {
+ grid-template-columns: repeat(6, 1fr);
+}
+
+@media (max-width: 768px) {
+ .card-grid {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ }
+}
+
+/* Card List */
+
+.card-list-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ transition: background 0.15s ease;
+}
+
+.card-list-item:hover {
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+}
+
+.card-list-item-info {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.card-list-item-name {
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.card-list-item-count {
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+.card-list-item-role {
+ color: var(--muted);
+ font-size: 0.75rem;
+ padding: 0.125rem 0.5rem;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+}
+
+/* Synthetic Card Placeholder */
+
+.card-sample.synthetic {
+ border: 1px dashed var(--border);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.synthetic-card-placeholder {
+ text-align: center;
+}
+
+.synthetic-card-icon {
+ font-size: 2rem;
+ opacity: 0.5;
+ margin-bottom: 0.5rem;
+}
+
+.synthetic-card-name {
+ font-weight: 600;
+ font-size: 0.875rem;
+ margin-bottom: 0.25rem;
+}
+
+.synthetic-card-reason {
+ font-size: 0.75rem;
+ color: var(--muted);
+}
+
+/* === PANELS === */
+
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin-bottom: 0.75rem;
+}
+
+/* Panel Variants */
+
+.panel-default {
+ background: var(--panel);
+}
+
+.panel-alt {
+ background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%);
+}
+
+.panel-dark {
+ background: #0f1115;
+}
+
+.panel-bordered {
+ background: transparent;
+}
+
+/* Panel Padding */
+
+.panel-padding-none {
+ padding: 0;
+}
+
+.panel-padding-sm {
+ padding: 0.5rem;
+}
+
+.panel-padding-md {
+ padding: 0.75rem;
+}
+
+.panel-padding-lg {
+ padding: 1.5rem;
+}
+
+/* Panel Structure */
+
+.panel-header {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.panel-body {
+ padding: 0.75rem;
+}
+
+.panel-footer {
+ padding: 0.75rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Info Panel */
+
+.panel-info {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.panel-info-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ flex: 1;
+}
+
+.panel-info-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.panel-info-text {
+ flex: 1;
+}
+
+.panel-info-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+ color: var(--text);
+}
+
+.panel-info-message {
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+
+.panel-info-action {
+ flex-shrink: 0;
+}
+
+/* Info Panel Variants */
+
+.panel-info-info {
+ border-color: var(--ring);
+ background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%);
+}
+
+.panel-info-success {
+ border-color: var(--ok);
+ background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%);
+}
+
+.panel-info-warning {
+ border-color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%);
+}
+
+.panel-info-error {
+ border-color: var(--err);
+ background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%);
+}
+
+/* Stat Panel */
+
+.panel-stat {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ text-align: center;
+ flex-direction: column;
+}
+
+.panel-stat-icon {
+ font-size: 2rem;
+}
+
+.panel-stat-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.panel-stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ color: var(--text);
+}
+
+.panel-stat-label {
+ font-size: 0.875rem;
+ color: var(--muted);
+ margin-top: 0.25rem;
+}
+
+.panel-stat-sublabel {
+ font-size: 0.75rem;
+ color: var(--muted);
+ margin-top: 0.125rem;
+}
+
+/* Stat Panel Variants */
+
+.panel-stat-primary {
+ border-color: var(--ring);
+}
+
+.panel-stat-primary .panel-stat-value {
+ color: var(--ring);
+}
+
+.panel-stat-success {
+ border-color: var(--ok);
+}
+
+.panel-stat-success .panel-stat-value {
+ color: var(--ok);
+}
+
+.panel-stat-warning {
+ border-color: var(--warn);
+}
+
+.panel-stat-warning .panel-stat-value {
+ color: var(--warn);
+}
+
+.panel-stat-error {
+ border-color: var(--err);
+}
+
+.panel-stat-error .panel-stat-value {
+ color: var(--err);
+}
+
+/* Collapsible Panel */
+
+.panel-collapsible .panel-header {
+ padding: 0;
+ border: none;
+}
+
+.panel-toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: transparent;
+ border: none;
+ color: var(--text);
+ cursor: pointer;
+ text-align: left;
+ border-radius: 10px 10px 0 0;
+ transition: background 0.15s ease;
+}
+
+.panel-toggle:hover {
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+}
+
+.panel-toggle-icon {
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 8px solid var(--text);
+ transition: transform 0.2s ease;
+}
+
+.panel-collapsed .panel-toggle-icon {
+ transform: rotate(-90deg);
+}
+
+.panel-expanded .panel-toggle-icon {
+ transform: rotate(0deg);
+}
+
+.panel-collapse-content {
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+}
+
+/* Panel Grid */
+
+.panel-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.panel-grid-cols-auto {
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+}
+
+.panel-grid-cols-1 {
+ grid-template-columns: 1fr;
+}
+
+.panel-grid-cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.panel-grid-cols-3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.panel-grid-cols-4 {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+@media (max-width: 768px) {
+ .panel-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Empty State Panel */
+
+.panel-empty-state {
+ text-align: center;
+ padding: 3rem 1.5rem;
+}
+
+.panel-empty-icon {
+ font-size: 4rem;
+ opacity: 0.5;
+ margin-bottom: 1rem;
+}
+
+.panel-empty-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem;
+ color: var(--text);
+}
+
+.panel-empty-message {
+ font-size: 0.95rem;
+ color: var(--muted);
+ margin: 0 0 1.5rem;
+}
+
+.panel-empty-action {
+ display: flex;
+ justify-content: center;
+}
+
+/* Loading Panel */
+
+.panel-loading {
+ text-align: center;
+ padding: 2rem 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+}
+
+.panel-loading-spinner {
+ width: 3rem;
+ height: 3rem;
+ border: 4px solid var(--border);
+ border-top-color: var(--ring);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.panel-loading-message {
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+/* =============================================================================
+ UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21)
+ ============================================================================= */
+
+/* Flex Row Layouts */
+
+.flex-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.flex-row-sm {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.flex-row-md {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.flex-row-lg {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.flex-row-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.flex-row-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.flex-row-start {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+}
+
+/* Flex Column Layouts */
+
+.flex-col {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.flex-col-sm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.flex-col-md {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.flex-col-lg {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.flex-col-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Flex Grid/Wrap Patterns */
+
+.flex-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.flex-grid-sm {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.flex-grid-md {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.flex-grid-lg {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+/* Spacing Utilities */
+
+.section-spacing {
+ margin-top: 2rem;
+}
+
+.section-spacing-sm {
+ margin-top: 1rem;
+}
+
+.section-spacing-lg {
+ margin-top: 3rem;
+}
+
+.content-spacing {
+ margin-bottom: 1rem;
+}
+
+.content-spacing-sm {
+ margin-bottom: 0.5rem;
+}
+
+.content-spacing-lg {
+ margin-bottom: 2rem;
+}
+
+/* Common Size Constraints */
+
+.max-w-content {
+ max-width: 1200px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.max-w-prose {
+ max-width: 65ch;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.max-w-form {
+ max-width: 600px;
+}
+
+/* Common Text Patterns */
+
+.text-muted {
+ color: var(--muted);
+ opacity: 0.85;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1.25;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.35;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+/* Screen Reader Only */
+
+.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;
+}
+
+/* =============================================================================
+ CARD HOVER SYSTEM (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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, 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: 0.5rem 0.6rem;
+ max-width: 320px;
+ font-size: 13px;
+ line-height: 1.4;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
+
+.card-meta ul {
+ margin: 0.25rem 0;
+ padding-left: 1.1rem;
+ list-style: disc;
+}
+
+.card-meta li {
+ margin: 0.1rem 0;
+}
+
+.card-meta .themes-list {
+ font-size: 18px;
+ line-height: 1.35;
+}
+
+.card-meta .label {
+ color: #94a3b8;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ display: block;
+ margin-bottom: 0.15rem;
+}
+
+.card-meta .themes-label {
+ color: var(--text);
+ font-size: 20px;
+ letter-spacing: 0.05em;
+}
+
+.card-meta .line + .line {
+ margin-top: 0.35rem;
+}
+
+.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;
+ }
+}
+
+/* Hide hover preview on narrow screens to avoid covering content */
+
+@media (max-width: 900px) {
+ .card-hover {
+ display: none !important;
+ }
+}
+
+/* =============================================================================
+ THEME BADGES (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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: 0.5px;
+}
+
+.theme-synergies {
+ font-size: 11px;
+ opacity: 0.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, 0.25);
+}
+
+.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);
+}
+
+.theme-detail-card h3 {
+ margin-top: 0;
+ margin-bottom: 0.4rem;
+}
+
+.theme-detail-card .desc {
+ margin-top: 0;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.theme-detail-card h4 {
+ margin-bottom: 0.35rem;
+ margin-top: 0.85rem;
+ font-size: 13px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.85;
+}
+
+.breadcrumb {
+ font-size: 12px;
+ margin-bottom: 0.4rem;
+}
+
+/* =============================================================================
+ HOVER CARD PANEL (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+/* 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, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset;
+}
+
+#hover-card-panel.is-payoff .hcp-img {
+ border-color: var(--accent, #38bdf8);
+}
+
+/* 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 {
+ -moz-columns: 2;
+ columns: 2;
+ -moz-column-gap: 18px;
+ 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 {
+ -moz-column-break-inside: avoid;
+ 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, 0.6);
+ border-radius: 9999px;
+ padding: 3px 10px;
+ font-size: 13px;
+ margin-right: 6px;
+ margin-top: 4px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+}
+
+/* Mobile hover panel */
+
+#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: 0.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 {
+ -moz-columns: 1;
+ 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, 0.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, 0.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: 0.7;
+}
+
+#hover-card-panel .hcp-close {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ 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, 0.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 0.22s ease;
+}
+
+/* =============================================================================
+ DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+/* 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, 0.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: 0.92;
+ backdrop-filter: blur(3px);
+}
+
+.dfc-toggle:hover,
+.dfc-toggle:focus {
+ opacity: 1;
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.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, 0.85);
+}
+
+.dfc-toggle[data-face='front'] {
+ background: rgba(15, 23, 42, 0.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, 0.3);
+}
+
+.list-row .dfc-toggle[data-face='front'] {
+ background: rgba(56, 189, 248, 0.2);
+}
+
+/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */
+
+/* =============================================================================
+ SITE FOOTER (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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;
+}
+
+/* =============================================================================
+ THEME PREVIEW FRAGMENT (themes/preview_fragment.html)
+ ============================================================================= */
+
+/* Preview header */
+
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.preview-header h3 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.preview-header .btn {
+ font-size: 12px;
+ line-height: 1;
+}
+
+/* Preview controls */
+
+.preview-controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ margin: 0.5rem 0 0.75rem;
+ font-size: 11px;
+}
+
+.preview-controls label {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.preview-controls .help-icon {
+ opacity: 0.55;
+ font-size: 10px;
+ cursor: help;
+}
+
+.preview-controls #preview-status {
+ opacity: 0.65;
+}
+
+/* Preview rationale */
+
+.preview-rationale {
+ margin: 0.25rem 0 0.85rem;
+ font-size: 11px;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+}
+
+.preview-rationale summary {
+ cursor: pointer;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+}
+
+.preview-rationale-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ margin-top: 0.4rem;
+}
+
+.preview-rationale-controls .btn {
+ font-size: 10px;
+ padding: 4px 8px;
+}
+
+.preview-rationale-controls #hover-compact-indicator {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+.preview-rationale ul {
+ margin: 0.5rem 0 0 0.9rem;
+ padding: 0;
+ list-style: disc;
+ line-height: 1.35;
+}
+
+.preview-rationale li .detail {
+ opacity: 0.75;
+}
+
+.preview-rationale li .instances {
+ opacity: 0.65;
+}
+
+/* Two column layout */
+
+.preview-two-col {
+ display: grid;
+ grid-template-columns: 1fr 480px;
+ gap: 1.25rem;
+ align-items: start;
+ position: relative;
+}
+
+.preview-col-divider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: calc(100% - 480px - 0.75rem);
+ width: 1px;
+ background: var(--border);
+ opacity: 0.55;
+}
+
+/* Section headers */
+
+.preview-section-header {
+ margin: 0.25rem 0 0.5rem;
+ font-size: 13px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.8;
+}
+
+.preview-section-hr {
+ border: 0;
+ border-top: 1px solid var(--border);
+ margin: 0.35rem 0 0.6rem;
+}
+
+/* Cards flow layout */
+
+.cards-flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+/* Group separators */
+
+.group-separator {
+ flex-basis: 100%;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ opacity: 0.65;
+ margin-top: 0.25rem;
+}
+
+.group-separator.mt-larger {
+ margin-top: 0.5rem;
+}
+
+/* Card sample */
+
+.card-sample {
+ width: 230px;
+}
+
+.card-sample .thumb-wrap {
+ position: relative;
+}
+
+.card-sample img.card-thumb {
+ filter: blur(4px);
+ transition: filter 0.35s ease;
+ background: linear-gradient(145deg, #0b0d12, #111b29);
+}
+
+.card-sample img.card-thumb[data-loaded] {
+ filter: blur(0);
+}
+
+/* Card badges */
+
+.dup-badge {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ background: #4b5563;
+ color: #fff;
+ font-size: 10px;
+ padding: 2px 5px;
+ border-radius: 10px;
+}
+
+.pin-btn {
+ 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;
+}
+
+/* Card metadata */
+
+.card-sample .meta {
+ font-size: 12px;
+ margin-top: 2px;
+}
+
+.card-sample .ci-ribbon {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 2px;
+ min-height: 10px;
+}
+
+.card-sample .nm {
+ font-weight: 600;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.card-sample .mana-line {
+ min-height: 14px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ font-size: 10px;
+}
+
+.card-sample .rarity-badge {
+ font-size: 9px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ opacity: 0.7;
+}
+
+.card-sample .role {
+ opacity: 0.75;
+ font-size: 11px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px;
+}
+
+.card-sample .reasons {
+ font-size: 9px;
+ opacity: 0.55;
+ line-height: 1.15;
+}
+
+/* Synthetic card */
+
+.card-sample.synthetic {
+ border: 1px dashed var(--border);
+ padding: 8px;
+ border-radius: 10px;
+ background: var(--panel-alt);
+}
+
+.card-sample.synthetic .name {
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.card-sample.synthetic .roles {
+ font-size: 11px;
+ opacity: 0.8;
+}
+
+.card-sample.synthetic .reasons-text {
+ font-size: 10px;
+ margin-top: 2px;
+ opacity: 0.6;
+ line-height: 1.15;
+}
+
+/* Spacer */
+
+.full-width-spacer {
+ flex-basis: 100%;
+ height: 0;
+}
+
+/* Commander grid */
+
+.commander-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ gap: 1rem;
+}
+
+.commander-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.commander-name {
+ font-size: 13px;
+ text-align: center;
+ line-height: 1.35;
+ font-weight: 600;
+ max-width: 230px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.commander-cell.synergy .commander-name {
+ font-size: 12px;
+ line-height: 1.3;
+ font-weight: 500;
+ opacity: 0.92;
+}
+
+/* Synergy commanders section */
+
+.synergy-commanders-section {
+ margin-top: 1rem;
+}
+
+.synergy-commanders-header {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-bottom: 0.4rem;
+}
+
+.synergy-commanders-header h5 {
+ margin: 0;
+ font-size: 11px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.75;
+}
+
+.derived-badge {
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 2px 6px;
+ font-size: 10px;
+ line-height: 1;
+}
+
+/* No commanders message */
+
+.no-commanders-message {
+ font-size: 11px;
+ opacity: 0.7;
+}
+
+/* Footer help text */
+
+.preview-help-text {
+ margin-top: 1rem;
+ font-size: 10px;
+ opacity: 0.65;
+ line-height: 1.4;
+}
+
+/* Skeleton loader */
+
+.preview-skeleton .sk-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.preview-skeleton .sk-bar {
+ height: 16px;
+ background: var(--hover);
+ border-radius: 4px;
+}
+
+.preview-skeleton .sk-bar.title {
+ width: 200px;
+}
+
+.preview-skeleton .sk-bar.close {
+ width: 60px;
+}
+
+.preview-skeleton .sk-cards {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 1rem;
+}
+
+.preview-skeleton .sk-card {
+ width: 230px;
+ height: 327px;
+ background: var(--hover);
+ border-radius: 10px;
+}
+
+/* Responsive */
+
+@media (max-width: 950px) {
+ .preview-two-col {
+ grid-template-columns: 1fr;
+ }
+
+ .preview-two-col .col-right {
+ order: -1;
+ }
+}
+
+footer.site-footer {
+ flex-shrink: 0;
+}
+
diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css
new file mode 100644
index 0000000..94c3b68
--- /dev/null
+++ b/code/web/static/tailwind.css
@@ -0,0 +1,3500 @@
+/* Tailwind CSS Entry Point */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Import custom CSS (not purged by Tailwind) */
+@import './custom.css';
+
+/* Base */
+:root{
+ /* MTG color palette (approx from provided values) */
+ --banner-h: 52px;
+ --sidebar-w: 260px;
+ --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);
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
+ --ring: #60a5fa; /* focus ring */
+ --ok: #16a34a; /* success */
+ --warn: #f59e0b; /* warning */
+ --err: #ef4444; /* error */
+ /* Surface overrides for specific regions (default to panel) */
+ --surface-banner: var(--panel);
+ --surface-banner-text: var(--text);
+ --surface-sidebar: var(--panel);
+ --surface-sidebar-text: var(--text);
+}
+
+/* Light blend between Slate and Parchment (leans gray) */
+[data-theme="light-blend"]{
+ --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
+ --panel: #ffffff; /* crisp panels for readability */
+ --text: #0b0d12;
+ --muted: #6b655d; /* slightly warm muted */
+ --border: #d6d1c7; /* neutral warm-gray border */
+ /* Slightly darker banner/sidebar for separation */
+ --surface-banner: #1a1b1e;
+ --surface-sidebar: #1a1b1e;
+ --surface-banner-text: #e8e8e8;
+ --surface-sidebar-text: #e8e8e8;
+}
+
+[data-theme="dark"]{
+ --bg: #0f0f10;
+ --panel: #1a1b1e;
+ --text: #e8e8e8;
+ --muted: #b6b8bd;
+ --border: #2a2b2f;
+}
+[data-theme="high-contrast"]{
+ --bg: #000;
+ --panel: #000;
+ --text: #fff;
+ --muted: #e5e7eb;
+ --border: #fff;
+ --ring: #ff0;
+}
+[data-theme="cb-friendly"]{
+ /* Tweak accents for color-blind friendliness */
+ --green-main: #2e7d32; /* darker green */
+ --red-main: #c62828; /* deeper red */
+ --blue-main: #1565c0; /* balanced blue */
+}
+*{box-sizing:border-box}
+html{height:100%; overflow-x:hidden; overflow-y:scroll; max-width:100vw;}
+body {
+ font-family: system-ui, Arial, sans-serif;
+ margin: 0;
+ color: var(--text);
+ background: var(--bg);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
+}
+/* Honor HTML hidden attribute across the app */
+[hidden] { display: none !important; }
+/* Accessible focus ring for keyboard navigation */
+.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+/* Top banner - simplified, no changes on sidebar toggle */
+.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); box-shadow:0 2px 6px rgba(0,0,0,.4); min-height: var(--banner-h); }
+.top-banner .top-inner{ margin:0; padding:.4rem 15px; display:flex; align-items:center; width:100%; box-sizing:border-box; }
+.top-banner h1{ font-size: 1.1rem; margin:0; margin-left: 25px; }
+.flex-row{ display: flex; align-items: center; gap: 25px; }
+.top-banner .banner-left{ width: 260px !important; flex-shrink: 0 !important; }
+/* Hide elements on all screen sizes */
+#btn-open-permalink{ display:none !important; }
+#banner-status{ display:none !important; }
+.top-banner #theme-reset{ display:none !important; }
+
+/* Layout */
+.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
+.sidebar{
+ background: var(--surface-sidebar);
+ color: var(--surface-sidebar-text);
+ border-right: 1px solid var(--border);
+ padding: 1rem;
+ position: fixed;
+ top: var(--banner-h);
+ left: 0;
+ bottom: 0;
+ overflow: auto;
+ width: var(--sidebar-w);
+ z-index: 9; /* below the banner (z=10) */
+ box-shadow: 2px 0 10px rgba(0,0,0,.18);
+ display: flex;
+ flex-direction: column;
+}
+.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
+
+/* Collapsible sidebar behavior */
+body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); }
+body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; }
+body.nav-collapsed .content{ grid-column: 2; }
+/* Sidebar collapsed state doesn't change banner grid on desktop anymore */
+/* Smooth hide/show on mobile while keeping fixed positioning */
+.sidebar{ transition: transform .2s ease-out, visibility .2s linear; overflow-x: hidden; }
+/* Suppress sidebar transitions during page load to prevent pop-in */
+body.no-transition .sidebar{ transition: none !important; }
+/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */
+body.htmx-settling .sidebar{ transition: none !important; }
+body.htmx-settling .layout{ transition: none !important; }
+body.htmx-settling .content{ transition: none !important; }
+body.htmx-settling *{ transition-duration: 0s !important; }
+
+/* Mobile tweaks */
+@media (max-width: 900px){
+ :root{ --sidebar-w: 240px; }
+ .layout{ grid-template-columns: 0 1fr; }
+ .sidebar{ transform: translateX(-100%); visibility: hidden; }
+ body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
+ body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
+ .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
+}
+
+/* Additional mobile spacing for bottom floating controls */
+@media (max-width: 720px) {
+ .content {
+ padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */
+ }
+}
+
+.brand h1{ display:none; }
+.brand{ padding-top: 0; margin-top: 0; }
+.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; margin-top: 0; padding-top: 0; }
+.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
+.dot.green{ background: var(--green-main); }
+.dot.blue{ background: var(--blue-main); }
+.dot.red{ background: var(--red-main); }
+.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
+.dot.black{ background: var(--black-light); }
+
+.nav{ display:flex; flex-direction:column; gap:.35rem; }
+.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
+.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
+
+/* Sidebar theme controls anchored at bottom */
+.sidebar .nav { flex: 1 1 auto; }
+.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
+.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; }
+.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; flex-wrap: nowrap; }
+.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; flex: 1 1 auto; min-width: 0; }
+.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); flex-shrink: 0; white-space: nowrap; }
+
+/* Simple two-column layout for inspect panel */
+.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
+.two-col .grow { min-width: 0; }
+.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); }
+@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
+
+/* Left-rail variant puts the image first */
+.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
+/* Ensure left-rail variant also collapses to 1 column on small screens */
+@media (max-width: 900px){
+ .two-col.two-col-left-rail{ grid-template-columns: 1fr; }
+ /* So the commander image doesn't dominate on mobile */
+ .two-col .card-preview{ max-width: 360px; margin: 0 auto; }
+ .two-col .card-preview img{ width: 100%; height: auto; }
+}
+.card-preview.card-sm{ max-width:200px; }
+
+/* Buttons, inputs */
+button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
+button:hover{ filter:brightness(1.05); }
+/* Anchor-style buttons */
+.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
+.btn:hover{ filter:brightness(1.05); text-decoration:none; }
+.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
+label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
+.color-identity{ display:inline-flex; align-items:center; gap:.35rem; }
+.color-identity .mana + .mana{ margin-left:4px; }
+.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; }
+.mana-W{ background:#f9fafb; border-color:#d1d5db; }
+.mana-U{ background:#3b82f6; border-color:#1d4ed8; }
+.mana-B{ background:#111827; border-color:#1f2937; }
+.mana-R{ background:#ef4444; border-color:#b91c1c; }
+.mana-G{ background:#10b981; border-color:#047857; }
+.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
+select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
+fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
+small, .muted{ color: var(--muted); }
+.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
+.partner-preview[hidden]{ display:none !important; }
+.partner-preview__header{ font-weight:600; }
+.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; }
+.partner-preview__art{ flex:0 0 auto; }
+.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); }
+.partner-preview__details{ flex:1 1 180px; min-width:0; }
+.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }
+.partner-preview__pairing{ margin-top:.35rem; }
+.partner-preview__themes{ margin-top:.35rem; font-size:12px; }
+.partner-preview--static{ margin-bottom:.5rem; }
+.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); }
+
+/* Toasts */
+.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
+.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
+.toast.hide{ opacity:0; transform: translateY(6px); }
+.toast.success{ border-color: rgba(22,163,74,.4); }
+.toast.error{ border-color: rgba(239,68,68,.45); }
+.toast.warn{ border-color: rgba(245,158,11,.45); }
+
+/* Skeletons */
+[data-skeleton]{ position: relative; }
+[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; }
+[data-skeleton-placeholder]{ display:none; pointer-events:none; }
+[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; }
+[data-skeleton][data-skeleton-overlay="false"]::after,
+[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; }
+[data-skeleton]::after{
+ content: '';
+ position: absolute; inset: 0;
+ border-radius: 8px;
+ background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
+ background-size: 200% 100%;
+ animation: shimmer 1.1s linear infinite;
+ display: none;
+}
+[data-skeleton].is-loading::after{ display:block; }
+[data-skeleton].is-loading::before{
+ content: attr(data-skeleton-label);
+ position:absolute;
+ top:50%;
+ left:50%;
+ transform:translate(-50%, -50%);
+ color: var(--muted);
+ font-size:.85rem;
+ text-align:center;
+ line-height:1.4;
+ max-width:min(92%, 360px);
+ padding:.3rem .5rem;
+ pointer-events:none;
+ z-index:1;
+ filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
+}
+[data-skeleton][data-skeleton-label=""]::before{ content:''; }
+@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
+
+/* Banner */
+.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
+.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
+.banner .subtitle{ color: var(--muted); font-size:.95rem; }
+
+/* Home actions */
+.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
+.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
+.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); }
+.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
+
+/* Home page darker buttons */
+.home-button.btn-secondary {
+ background: #1a1d24;
+ border-color: #2a2d35;
+}
+.home-button.btn-secondary:hover {
+ background: #22252d;
+ border-color: #3a3d45;
+}
+.home-button.btn-primary {
+ background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
+ border-color: #2a5580;
+}
+.home-button.btn-primary:hover {
+ background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
+ border-color: #3a6590;
+}
+
+/* Card grid for added cards (responsive, compact tiles) */
+.card-grid{
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
+ gap: .5rem;
+ margin-top:.5rem;
+ justify-content: start; /* pack as many as possible per row */
+ /* Prevent scroll chaining bounce that can cause flicker near bottom */
+ overscroll-behavior: contain;
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 640px 420px;
+}
+@media (max-width: 420px){
+ .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .card-tile{ width: 100%; }
+ .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; }
+}
+.card-tile{
+ width:170px;
+ position: relative;
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:6px;
+ padding:.25rem .25rem .4rem;
+ text-align:center;
+}
+.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
+.card-tile.locked{
+ /* Subtle yellow/goldish-white accent for locked cards */
+ border-color: #f5e6a8; /* soft parchment gold */
+ box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
+}
+.card-tile.must-include{
+ border-color: rgba(74,222,128,.85);
+ box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
+}
+.card-tile.must-exclude{
+ border-color: rgba(239,68,68,.85);
+ box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
+ opacity: .95;
+}
+.card-tile.must-include.must-exclude{
+ border-color: rgba(249,115,22,.85);
+ box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset;
+}
+.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
+.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
+.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
+
+.must-have-controls{
+ display:flex;
+ justify-content:center;
+ gap:.35rem;
+ flex-wrap:wrap;
+ margin-top:.35rem;
+}
+.must-have-btn{
+ border:1px solid var(--border);
+ background:rgba(30,41,59,.6);
+ color:#f8fafc;
+ font-size:11px;
+ text-transform:uppercase;
+ letter-spacing:.06em;
+ padding:.25rem .6rem;
+ border-radius:9999px;
+ cursor:pointer;
+ transition: all .18s ease;
+}
+.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{
+ border-color: rgba(74,222,128,.75);
+ background: rgba(74,222,128,.18);
+ color: #bbf7d0;
+ box-shadow: 0 0 0 1px rgba(16,185,129,.25);
+}
+.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{
+ border-color: rgba(239,68,68,.75);
+ background: rgba(239,68,68,.18);
+ color: #fecaca;
+ box-shadow: 0 0 0 1px rgba(239,68,68,.25);
+}
+.must-have-btn:focus-visible{
+ outline:2px solid rgba(59,130,246,.6);
+ outline-offset:2px;
+}
+.card-tile.must-exclude .must-have-btn.include[data-active="0"],
+.card-tile.must-include .must-have-btn.exclude[data-active="0"]{
+ opacity:.65;
+}
+
+.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
+.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
+.alt-option{ display:block !important; width:100%; max-width:100%; text-align:left; 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; }
+
+/* Shared ownership badge for card tiles and stacked images */
+.owned-badge{
+ position:absolute;
+ top:6px;
+ left:6px;
+ background:rgba(17,24,39,.9);
+ color:#e5e7eb;
+ border:1px solid var(--border);
+ border-radius:12px;
+ font-size:12px;
+ line-height:18px;
+ height:18px;
+ min-width:18px;
+ padding:0 6px;
+ text-align:center;
+ pointer-events:none;
+ z-index:2;
+}
+
+/* Step 1 candidate grid (200px-wide scaled images) */
+.candidate-grid{
+ display:grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap:.75rem;
+}
+.candidate-tile{
+ background: var(--panel);
+ border:1px solid var(--border);
+ border-radius:8px;
+ padding:.4rem;
+}
+.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
+.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; }
+.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
+.candidate-tile .name{ font-weight:600; font-size:.95rem; }
+.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
+
+/* Deck summary: highlight game changers */
+.game-changer { color: var(--green-main); }
+.stack-card.game-changer { outline: 2px solid var(--green-main); }
+
+/* Image button inside card tiles */
+.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
+
+/* Stage Navigator */
+.stage-nav { margin:.5rem 0 1rem; }
+.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
+.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
+.stage-nav .stage-item.done .stage-link { opacity:.75; }
+.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
+.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
+.stage-nav .name { font-size:12px; }
+
+/* Build controls sticky box tweaks */
+.build-controls {
+ position: sticky;
+ top: calc(var(--banner-offset, 48px) + 6px);
+ z-index: 100;
+ background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin: 0.5rem 0;
+ box-shadow: 0 4px 12px rgba(0,0,0,.25);
+}
+
+@media (max-width: 1024px){
+ :root { --banner-offset: 56px; }
+ .build-controls {
+ position: fixed !important; /* Fixed to viewport instead of sticky */
+ bottom: 0 !important; /* Anchor to bottom of screen */
+ left: 0 !important;
+ right: 0 !important;
+ top: auto !important; /* Override top positioning */
+ border-radius: 0 !important; /* Remove border radius for full width */
+ margin: 0 !important; /* Remove margins for full edge-to-edge */
+ padding: 0.5rem !important; /* Reduced padding */
+ box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */
+ border-left: none !important;
+ border-right: none !important;
+ border-bottom: none !important; /* Remove bottom border */
+ background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
+ z-index: 1000 !important; /* Higher z-index to ensure it's above content */
+ }
+}
+@media (min-width: 721px){
+ :root { --banner-offset: 48px; }
+}
+
+/* Progress bar */
+.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
+.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
+.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
+
+/* Chips */
+.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
+.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
+.chip:hover { background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); }
+.chip.active {
+ background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15));
+ border-color: #3b82f6;
+ color: #60a5fa;
+ font-weight: 600;
+ box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset;
+}
+.chip.active:hover {
+ background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25));
+ border-color: #60a5fa;
+}
+
+/* Cards toolbar */
+.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
+.cards-toolbar input[type="text"]{ min-width: 220px; }
+.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
+.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
+
+/* Collapse groups and reason toggle */
+.group{ margin:.5rem 0; }
+.group-header{ display:flex; align-items:center; gap:.5rem; }
+.group-header h5{ margin:.4rem 0; }
+.group-header .count{ color: var(--muted); font-size:12px; }
+.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
+.group-grid[data-collapsed]{ display:none; }
+.hide-reasons .card-tile .reason{ display:none; }
+.card-tile.force-show .reason{ display:block !important; }
+.card-tile.force-hide .reason{ display:none !important; }
+.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
+.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
+.chips-inline .chip{ cursor:pointer; user-select:none; }
+
+/* Inline error banner */
+.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
+.inline-error-banner .muted{ color:#fda4af; }
+
+/* Alternatives panel */
+.alts ul{ list-style:none; padding:0; margin:0; }
+.alts li{ display:flex; align-items:center; gap:.4rem; }
+/* LQIP blur/fade-in for thumbnails */
+img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
+img.lqip.loaded { filter: blur(0); opacity: 1; }
+
+/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
+@media (prefers-reduced-motion: reduce) {
+ * { scroll-behavior: auto !important; }
+ img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
+}
+
+/* Virtualization wrapper should mirror grid to keep multi-column flow */
+.virt-wrapper { display: grid; }
+
+/* Mobile responsive fixes for horizontal scrolling issues */
+@media (max-width: 768px) {
+ /* Prevent horizontal overflow */
+ html, body {
+ overflow-x: hidden !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ }
+
+ /* Test hand responsive adjustments */
+ #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; }
+
+ /* Modal & form layout fixes (original block retained inside media query) */
+ /* Fix modal layout on mobile */
+ .modal {
+ padding: 10px !important;
+ box-sizing: border-box;
+ }
+ .modal-content {
+ width: 100% !important;
+ max-width: calc(100vw - 20px) !important;
+ box-sizing: border-box !important;
+ overflow-x: hidden !important;
+ }
+ /* Force single column for include/exclude grid */
+ .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; }
+ /* Fix basics grid */
+ .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; }
+ /* Ensure all inputs and textareas fit properly */
+ .modal input,
+ .modal textarea,
+ .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; }
+ /* Fix chips containers */
+ .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; }
+ /* Ensure fieldsets don't overflow */
+ .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; }
+ /* Fix any inline styles that might cause overflow */
+ .modal fieldset > div,
+ .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; }
+}
+
+@media (max-width: 640px){
+ #test-hand{ --card-w: 150px !important; --card-h: 210px !important; }
+ /* Generic stack shrink */
+ .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; }
+}
+
+@media (max-width: 560px){
+ #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; }
+ #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; }
+ #test-hand .stack-card{ flex:0 0 auto; }
+ .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; }
+}
+
+@media (max-width: 480px) {
+ .modal-content {
+ padding: 12px !important;
+ margin: 5px !important;
+ }
+
+ .modal fieldset {
+ padding: 8px !important;
+ margin: 6px 0 !important;
+ }
+
+ /* Enhanced mobile build controls */
+ .build-controls {
+ flex-direction: column !important;
+ gap: 0.25rem !important; /* Reduced gap */
+ align-items: stretch !important;
+ padding: 0.5rem !important; /* Reduced padding */
+ }
+
+ /* Two-column grid layout for mobile build controls */
+ .build-controls {
+ display: grid !important;
+ grid-template-columns: 1fr 1fr !important; /* Two equal columns */
+ grid-gap: 0.25rem !important;
+ align-items: stretch !important;
+ }
+
+ .build-controls form {
+ display: contents !important; /* Allow form contents to participate in grid */
+ width: auto !important;
+ }
+
+ .build-controls button {
+ flex: none !important;
+ padding: 0.4rem 0.5rem !important; /* Much smaller padding */
+ font-size: 12px !important; /* Smaller font */
+ min-height: 36px !important; /* Smaller minimum height */
+ line-height: 1.2 !important;
+ width: 100% !important; /* Full width within grid cell */
+ box-sizing: border-box !important;
+ white-space: nowrap !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ /* Hide non-essential elements on mobile to keep it clean */
+ .build-controls .sep,
+ .build-controls .replace-toggle,
+ .build-controls label[style*="margin-left"] {
+ display: none !important;
+ }
+
+ .build-controls .sep {
+ display: none !important; /* Hide separators on mobile */
+ }
+}
+
+/* Desktop sizing for Test Hand */
+@media (min-width: 900px) {
+ #test-hand { --card-w: 280px !important; --card-h: 392px !important; }
+}
+
+/* Analytics accordion styling */
+.analytics-accordion {
+ transition: all 0.2s ease;
+}
+
+.analytics-accordion summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.analytics-accordion summary:hover {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+.analytics-accordion summary:active {
+ transform: scale(0.99);
+}
+
+.analytics-accordion[open] summary {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ margin-bottom: 0;
+}
+
+.analytics-accordion .analytics-content {
+ animation: accordion-slide-down 0.3s ease-out;
+}
+
+@keyframes accordion-slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.analytics-placeholder .skeleton-pulse {
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+/* Ideals Slider Styling */
+.ideals-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 6px;
+ background: var(--border);
+ border-radius: 3px;
+ outline: none;
+}
+
+.ideals-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border-radius: 50%;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.ideals-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+}
+
+.ideals-slider::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ background: var(--ring);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.ideals-slider::-moz-range-thumb:hover {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
+}
+
+.slider-value {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+/* ========================================
+ Card Browser Styles
+ ======================================== */
+
+/* Card browser container */
+.card-browser-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+/* Filter panel */
+.card-browser-filters {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+.filter-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.filter-row label {
+ font-weight: 600;
+ min-width: 80px;
+ color: var(--text);
+ font-size: 0.95rem;
+}
+
+.filter-row select,
+.filter-row input[type="text"],
+.filter-row input[type="search"] {
+ flex: 1;
+ min-width: 150px;
+ max-width: 300px;
+}
+
+/* Search bar styling */
+.card-search-wrapper {
+ position: relative;
+ flex: 1;
+ max-width: 100%;
+}
+
+.card-search-wrapper input[type="search"] {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 1rem;
+}
+
+/* Results count and info bar */
+.card-browser-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+}
+
+.results-count {
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+.page-indicator {
+ font-size: 0.95rem;
+ color: var(--text);
+ font-weight: 600;
+}
+
+/* Card browser grid */
+.card-browser-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 240px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-height: 480px;
+ justify-content: start;
+}
+
+/* Individual card tile in browser */
+.card-browser-tile {
+ break-inside: avoid;
+ display: flex;
+ flex-direction: column;
+ background: var(--card-bg, #1a1d24);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ cursor: pointer;
+}
+
+.card-browser-tile:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%);
+}
+
+.card-browser-tile-image {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 488/680;
+ overflow: hidden;
+ background: #0a0b0e;
+}
+
+.card-browser-tile-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ transition: transform 0.3s ease;
+}
+
+.card-browser-tile:hover .card-browser-tile-image img {
+ transform: scale(1.05);
+}
+
+.card-browser-tile-info {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.card-browser-tile-name {
+ font-weight: 600;
+ font-size: 0.95rem;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+}
+
+.card-browser-tile-type {
+ font-size: 0.85rem;
+ color: var(--muted);
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+}
+
+.card-browser-tile-stats {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.85rem;
+}
+
+.card-browser-tile-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-top: 0.25rem;
+}
+
+.card-browser-tile-tags .tag {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.4rem;
+ background: rgba(148, 163, 184, 0.15);
+ color: var(--muted);
+ border-radius: 3px;
+ white-space: nowrap;
+}
+
+/* Card Details button on tiles */
+.card-details-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--primary);
+ color: white;
+ text-decoration: none;
+ border-radius: 6px;
+ font-weight: 500;
+ font-size: 0.85rem;
+ transition: all 0.2s;
+ margin-top: 0.5rem;
+ border: none;
+ cursor: pointer;
+}
+
+.card-details-btn:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
+}
+
+.card-details-btn svg {
+ flex-shrink: 0;
+}
+
+/* Card Preview Modal */
+.preview-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.85);
+ z-index: 9999;
+ align-items: center;
+ justify-content: center;
+}
+
+.preview-modal.active {
+ display: flex;
+}
+
+.preview-content {
+ position: relative;
+ max-width: 90%;
+ max-height: 90%;
+}
+
+.preview-content img {
+ max-width: 100%;
+ max-height: 90vh;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+.preview-close {
+ position: absolute;
+ top: -40px;
+ right: 0;
+ background: rgba(255, 255, 255, 0.9);
+ color: #000;
+ border: none;
+ border-radius: 50%;
+ width: 36px;
+ height: 36px;
+ font-size: 24px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+}
+
+.preview-close:hover {
+ background: #fff;
+ transform: scale(1.1);
+}
+
+/* Pagination controls */
+.card-browser-pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 0;
+ flex-wrap: wrap;
+}
+
+.card-browser-pagination .btn {
+ min-width: 120px;
+}
+
+.card-browser-pagination .page-info {
+ font-size: 0.95rem;
+ color: var(--text);
+ padding: 0 1rem;
+}
+
+/* No results message */
+.no-results {
+ text-align: center;
+ padding: 3rem 1rem;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.no-results-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 0.5rem;
+}
+
+.no-results-message {
+ color: var(--muted);
+ margin-bottom: 1rem;
+ line-height: 1.5;
+}
+
+.no-results-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.no-results-filter-tag {
+ padding: 0.25rem 0.75rem;
+ background: rgba(148, 163, 184, 0.15);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ color: var(--text);
+}
+
+/* Loading indicator */
+.card-browser-loading {
+ text-align: center;
+ padding: 2rem;
+ color: var(--muted);
+}
+
+/* Responsive adjustments */
+/* Large tablets and below - reduce to ~180px cards */
+@media (max-width: 1024px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(200px, 200px));
+ }
+}
+
+/* Tablets - reduce to ~160px cards */
+@media (max-width: 768px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
+ gap: 0.5rem;
+ padding: 0.5rem;
+ }
+
+ .filter-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-row label {
+ min-width: auto;
+ }
+
+ .filter-row select,
+ .filter-row input {
+ max-width: 100%;
+ }
+
+ .card-browser-info {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+/* Small tablets/large phones - reduce to ~140px cards */
+@media (max-width: 600px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 160px));
+ gap: 0.5rem;
+ }
+}
+
+/* Phones - 2 column layout with flexible width */
+@media (max-width: 480px) {
+ .card-browser-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.375rem;
+ }
+
+ .card-browser-tile-name {
+ font-size: 0.85rem;
+ }
+
+ .card-browser-tile-type {
+ font-size: 0.75rem;
+ }
+
+ .card-browser-tile-info {
+ padding: 0.5rem;
+ }
+}
+
+/* Theme chips for multi-select */
+.theme-chip {
+ display: inline-flex;
+ align-items: center;
+ background: var(--primary-bg);
+ color: var(--primary-fg);
+ padding: 0.25rem 0.75rem;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ border: 1px solid var(--border-color);
+}
+
+.theme-chip button {
+ margin-left: 0.5rem;
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ font-weight: bold;
+ font-size: 1.2rem;
+ line-height: 1;
+}
+
+.theme-chip button:hover {
+ color: var(--error-color);
+}
+
+/* Card Detail Page Styles */
+.card-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+.card-tag {
+ background: var(--ring);
+ color: white;
+ padding: 0.35rem 0.75rem;
+ border-radius: 16px;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.back-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--panel);
+ color: var(--text);
+ text-decoration: none;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ font-weight: 500;
+ transition: all 0.2s;
+ margin-bottom: 2rem;
+}
+
+.back-button:hover {
+ background: var(--ring);
+ color: white;
+ border-color: var(--ring);
+}
+
+/* Card Detail Page - Main Card Image */
+.card-image-large {
+ flex: 0 0 auto;
+ max-width: 360px !important;
+ width: 100%;
+}
+
+.card-image-large img {
+ width: 100%;
+ height: auto;
+ border-radius: 12px;
+}
+
+/* ============================================
+ M2 Component Library Styles
+ ============================================ */
+
+/* === BUTTONS === */
+/* Button Base - enhanced from existing .btn */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ background: var(--blue-main);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ text-decoration: none;
+ line-height: 1.5;
+ font-weight: 500;
+ transition: filter 0.15s ease, transform 0.05s ease;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ filter: brightness(1.1);
+ text-decoration: none;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn:disabled,
+.btn.disabled,
+.btn[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* Button Variants */
+.btn-primary {
+ background: var(--blue-main);
+ color: #fff;
+}
+
+.btn-secondary {
+ background: var(--muted);
+ color: var(--text);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-ghost:hover {
+ background: var(--panel);
+ border-color: var(--text);
+}
+
+.btn-danger {
+ background: var(--err);
+ color: #fff;
+}
+
+/* Button Sizes */
+.btn-sm {
+ padding: 0.25rem 0.75rem;
+ font-size: 0.875rem;
+}
+
+.btn-md {
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+}
+
+.btn-lg {
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+/* Icon Button */
+.btn-icon {
+ padding: 0.5rem;
+ aspect-ratio: 1;
+ justify-content: center;
+}
+
+.btn-icon.btn-sm {
+ padding: 0.25rem;
+ font-size: 1rem;
+}
+
+/* Close Button */
+.btn-close {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ font-size: 1.5rem;
+ line-height: 1;
+ z-index: 10;
+}
+
+/* Tag/Chip Button */
+.btn-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 0.25rem 0.75rem;
+ font-size: 0.875rem;
+ transition: all 0.15s ease;
+}
+
+.btn-tag:hover {
+ background: var(--border);
+ border-color: var(--text);
+}
+
+.btn-tag-selected {
+ background: var(--blue-main);
+ color: #fff;
+ border-color: var(--blue-main);
+}
+
+.btn-tag-remove {
+ background: transparent;
+ border: none;
+ color: inherit;
+ padding: 0;
+ margin: 0;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0.7;
+}
+
+.btn-tag-remove:hover {
+ opacity: 1;
+}
+
+/* Button Group */
+.btn-group {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.btn-group-left {
+ justify-content: flex-start;
+}
+
+.btn-group-center {
+ justify-content: center;
+}
+
+.btn-group-right {
+ justify-content: flex-end;
+}
+
+.btn-group-between {
+ justify-content: space-between;
+}
+
+/* Legacy action-btn compatibility */
+.action-btn {
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+/* === MODALS === */
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(2px);
+ z-index: -1;
+}
+
+.modal-content {
+ position: relative;
+ background: #0f1115;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ padding: 1rem;
+ width: 100%;
+ max-height: min(92vh, 100%);
+ display: flex;
+ flex-direction: column;
+}
+
+/* Modal Sizes */
+.modal-sm .modal-content {
+ max-width: 480px;
+}
+
+.modal-md .modal-content {
+ max-width: 620px;
+}
+
+.modal-lg .modal-content {
+ max-width: 720px;
+}
+
+.modal-xl .modal-content {
+ max-width: 960px;
+}
+
+/* Modal Position */
+.modal-center {
+ align-items: center;
+}
+
+.modal-top {
+ align-items: flex-start;
+ padding-top: 2rem;
+}
+
+/* Modal Scrollable */
+.modal-scrollable .modal-content {
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+/* Modal Structure */
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding-right: 2rem;
+}
+
+.modal-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.modal-body {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.modal-footer {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Modal Variants */
+.modal-confirm .modal-body {
+ padding: 1rem 0;
+ font-size: 0.95rem;
+}
+
+.modal-alert {
+ text-align: center;
+}
+
+.modal-alert .modal-body {
+ padding: 1.5rem 0;
+}
+
+.modal-alert .alert-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+}
+
+.modal-alert-info .alert-icon::before {
+ content: 'ℹ️';
+}
+
+.modal-alert-success .alert-icon::before {
+ content: '✅';
+}
+
+.modal-alert-warning .alert-icon::before {
+ content: '⚠️';
+}
+
+.modal-alert-error .alert-icon::before {
+ content: '❌';
+}
+
+/* === FORMS === */
+.form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.form-label {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.form-required {
+ color: var(--err);
+ font-weight: bold;
+}
+
+.form-input-wrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.form-input,
+.form-textarea,
+.form-select {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ width: 100%;
+}
+
+.form-input:focus,
+.form-textarea:focus,
+.form-select:focus {
+ outline: none;
+ border-color: var(--ring);
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+.form-input:disabled,
+.form-textarea:disabled,
+.form-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.form-textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+.form-input-number {
+ max-width: 150px;
+}
+
+.form-input-file {
+ padding: 0.375rem 0.5rem;
+}
+
+/* Checkbox and Radio */
+.form-field-checkbox,
+.form-field-radio {
+ flex-direction: row;
+ align-items: flex-start;
+}
+
+.form-checkbox-label,
+.form-radio-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-weight: normal;
+}
+
+.form-checkbox,
+.form-radio {
+ width: 1.125rem;
+ height: 1.125rem;
+ border: 1px solid var(--border);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.form-checkbox {
+ border-radius: 4px;
+}
+
+.form-radio {
+ border-radius: 50%;
+}
+
+.form-checkbox:checked,
+.form-radio:checked {
+ background: var(--blue-main);
+ border-color: var(--blue-main);
+}
+
+.form-checkbox:focus,
+.form-radio:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
+}
+
+.form-radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Form Help and Error Text */
+.form-help-text {
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-top: -0.25rem;
+}
+
+.form-error-text {
+ font-size: 0.8rem;
+ color: var(--err);
+ margin-top: -0.25rem;
+}
+
+.form-field-error .form-input,
+.form-field-error .form-textarea,
+.form-field-error .form-select {
+ border-color: var(--err);
+}
+
+/* === CARD DISPLAY COMPONENTS === */
+/* Card Thumbnail Container */
+.card-thumb-container {
+ position: relative;
+ display: inline-block;
+}
+
+.card-thumb {
+ display: block;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: #0b0d12;
+ object-fit: cover;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.card-thumb:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* Card Thumbnail Sizes */
+.card-thumb-small .card-thumb {
+ width: 160px;
+ height: auto;
+}
+
+.card-thumb-medium .card-thumb {
+ width: 230px;
+ height: auto;
+}
+
+.card-thumb-large .card-thumb {
+ width: 360px;
+ height: auto;
+}
+
+/* Card Flip Button */
+.card-flip-btn {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ padding: 0.375rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(4px);
+ transition: background 0.15s ease;
+ z-index: 5;
+}
+
+.card-flip-btn:hover {
+ background: rgba(0, 0, 0, 0.9);
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.card-flip-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+/* Card Name Label */
+.card-name-label {
+ font-size: 0.75rem;
+ margin-top: 0.375rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 600;
+ text-align: center;
+}
+
+/* Card Hover Popup */
+.card-popup {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.card-popup-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(2px);
+ z-index: -1;
+}
+
+.card-popup-content {
+ position: relative;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ padding: 1rem;
+ max-width: 400px;
+ width: 100%;
+}
+
+.card-popup-image {
+ position: relative;
+ margin-bottom: 1rem;
+}
+
+.card-popup-image img {
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+
+.card-popup-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.card-popup-name {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.card-popup-role {
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+
+.card-popup-role span {
+ color: var(--text);
+ font-weight: 500;
+}
+
+.card-popup-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.card-popup-tag {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 0.25rem 0.5rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+}
+
+.card-popup-tag-highlight {
+ background: var(--blue-main);
+ color: #fff;
+ border-color: var(--blue-main);
+}
+
+.card-popup-close {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ width: 2rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ line-height: 1;
+ cursor: pointer;
+ backdrop-filter: blur(4px);
+}
+
+.card-popup-close:hover {
+ background: rgba(0, 0, 0, 0.9);
+}
+
+/* Card Grid */
+.card-grid {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+}
+
+.card-grid-cols-auto {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+}
+
+.card-grid-cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.card-grid-cols-3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.card-grid-cols-4 {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.card-grid-cols-5 {
+ grid-template-columns: repeat(5, 1fr);
+}
+
+.card-grid-cols-6 {
+ grid-template-columns: repeat(6, 1fr);
+}
+
+@media (max-width: 768px) {
+ .card-grid {
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ }
+}
+
+/* Card List */
+.card-list-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--panel);
+ transition: background 0.15s ease;
+}
+
+.card-list-item:hover {
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+}
+
+.card-list-item-info {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.card-list-item-name {
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.card-list-item-count {
+ color: var(--muted);
+ font-size: 0.875rem;
+}
+
+.card-list-item-role {
+ color: var(--muted);
+ font-size: 0.75rem;
+ padding: 0.125rem 0.5rem;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+}
+
+/* Synthetic Card Placeholder */
+.card-sample.synthetic {
+ border: 1px dashed var(--border);
+ border-radius: 10px;
+ background: var(--panel);
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.synthetic-card-placeholder {
+ text-align: center;
+}
+
+.synthetic-card-icon {
+ font-size: 2rem;
+ opacity: 0.5;
+ margin-bottom: 0.5rem;
+}
+
+.synthetic-card-name {
+ font-weight: 600;
+ font-size: 0.875rem;
+ margin-bottom: 0.25rem;
+}
+
+.synthetic-card-reason {
+ font-size: 0.75rem;
+ color: var(--muted);
+}
+
+/* === PANELS === */
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin-bottom: 0.75rem;
+}
+
+/* Panel Variants */
+.panel-default {
+ background: var(--panel);
+}
+
+.panel-alt {
+ background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%);
+}
+
+.panel-dark {
+ background: #0f1115;
+}
+
+.panel-bordered {
+ background: transparent;
+}
+
+/* Panel Padding */
+.panel-padding-none {
+ padding: 0;
+}
+
+.panel-padding-sm {
+ padding: 0.5rem;
+}
+
+.panel-padding-md {
+ padding: 0.75rem;
+}
+
+.panel-padding-lg {
+ padding: 1.5rem;
+}
+
+/* Panel Structure */
+.panel-header {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.panel-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.panel-body {
+ padding: 0.75rem;
+}
+
+.panel-footer {
+ padding: 0.75rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Info Panel */
+.panel-info {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.panel-info-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ flex: 1;
+}
+
+.panel-info-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.panel-info-text {
+ flex: 1;
+}
+
+.panel-info-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+ color: var(--text);
+}
+
+.panel-info-message {
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+
+.panel-info-action {
+ flex-shrink: 0;
+}
+
+/* Info Panel Variants */
+.panel-info-info {
+ border-color: var(--ring);
+ background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%);
+}
+
+.panel-info-success {
+ border-color: var(--ok);
+ background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%);
+}
+
+.panel-info-warning {
+ border-color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%);
+}
+
+.panel-info-error {
+ border-color: var(--err);
+ background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%);
+}
+
+/* Stat Panel */
+.panel-stat {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ text-align: center;
+ flex-direction: column;
+}
+
+.panel-stat-icon {
+ font-size: 2rem;
+}
+
+.panel-stat-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.panel-stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ color: var(--text);
+}
+
+.panel-stat-label {
+ font-size: 0.875rem;
+ color: var(--muted);
+ margin-top: 0.25rem;
+}
+
+.panel-stat-sublabel {
+ font-size: 0.75rem;
+ color: var(--muted);
+ margin-top: 0.125rem;
+}
+
+/* Stat Panel Variants */
+.panel-stat-primary {
+ border-color: var(--ring);
+}
+
+.panel-stat-primary .panel-stat-value {
+ color: var(--ring);
+}
+
+.panel-stat-success {
+ border-color: var(--ok);
+}
+
+.panel-stat-success .panel-stat-value {
+ color: var(--ok);
+}
+
+.panel-stat-warning {
+ border-color: var(--warn);
+}
+
+.panel-stat-warning .panel-stat-value {
+ color: var(--warn);
+}
+
+.panel-stat-error {
+ border-color: var(--err);
+}
+
+.panel-stat-error .panel-stat-value {
+ color: var(--err);
+}
+
+/* Collapsible Panel */
+.panel-collapsible .panel-header {
+ padding: 0;
+ border: none;
+}
+
+.panel-toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: transparent;
+ border: none;
+ color: var(--text);
+ cursor: pointer;
+ text-align: left;
+ border-radius: 10px 10px 0 0;
+ transition: background 0.15s ease;
+}
+
+.panel-toggle:hover {
+ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%);
+}
+
+.panel-toggle-icon {
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 8px solid var(--text);
+ transition: transform 0.2s ease;
+}
+
+.panel-collapsed .panel-toggle-icon {
+ transform: rotate(-90deg);
+}
+
+.panel-expanded .panel-toggle-icon {
+ transform: rotate(0deg);
+}
+
+.panel-collapse-content {
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+}
+
+/* Panel Grid */
+.panel-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.panel-grid-cols-auto {
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+}
+
+.panel-grid-cols-1 {
+ grid-template-columns: 1fr;
+}
+
+.panel-grid-cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.panel-grid-cols-3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.panel-grid-cols-4 {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+@media (max-width: 768px) {
+ .panel-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Empty State Panel */
+.panel-empty-state {
+ text-align: center;
+ padding: 3rem 1.5rem;
+}
+
+.panel-empty-icon {
+ font-size: 4rem;
+ opacity: 0.5;
+ margin-bottom: 1rem;
+}
+
+.panel-empty-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem;
+ color: var(--text);
+}
+
+.panel-empty-message {
+ font-size: 0.95rem;
+ color: var(--muted);
+ margin: 0 0 1.5rem;
+}
+
+.panel-empty-action {
+ display: flex;
+ justify-content: center;
+}
+
+/* Loading Panel */
+.panel-loading {
+ text-align: center;
+ padding: 2rem 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+}
+
+.panel-loading-spinner {
+ width: 3rem;
+ height: 3rem;
+ border: 4px solid var(--border);
+ border-top-color: var(--ring);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.panel-loading-message {
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+/* =============================================================================
+ UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21)
+ ============================================================================= */
+
+/* Flex Row Layouts */
+.flex-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.flex-row-sm {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.flex-row-md {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.flex-row-lg {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.flex-row-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.flex-row-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.flex-row-start {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+}
+
+/* Flex Column Layouts */
+.flex-col {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.flex-col-sm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.flex-col-md {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.flex-col-lg {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.flex-col-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Flex Grid/Wrap Patterns */
+.flex-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.flex-grid-sm {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.flex-grid-md {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.flex-grid-lg {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+/* Spacing Utilities */
+.section-spacing {
+ margin-top: 2rem;
+}
+
+.section-spacing-sm {
+ margin-top: 1rem;
+}
+
+.section-spacing-lg {
+ margin-top: 3rem;
+}
+
+.content-spacing {
+ margin-bottom: 1rem;
+}
+
+.content-spacing-sm {
+ margin-bottom: 0.5rem;
+}
+
+.content-spacing-lg {
+ margin-bottom: 2rem;
+}
+
+/* Common Size Constraints */
+.max-w-content {
+ max-width: 1200px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.max-w-prose {
+ max-width: 65ch;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.max-w-form {
+ max-width: 600px;
+}
+
+/* Common Text Patterns */
+.text-muted {
+ color: var(--muted);
+ opacity: 0.85;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1.25;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.35;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+/* Screen Reader Only */
+.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;
+}
+
+/* =============================================================================
+ CARD HOVER SYSTEM (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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, 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: 0.5rem 0.6rem;
+ max-width: 320px;
+ font-size: 13px;
+ line-height: 1.4;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
+
+.card-meta ul {
+ margin: 0.25rem 0;
+ padding-left: 1.1rem;
+ list-style: disc;
+}
+
+.card-meta li {
+ margin: 0.1rem 0;
+}
+
+.card-meta .themes-list {
+ font-size: 18px;
+ line-height: 1.35;
+}
+
+.card-meta .label {
+ color: #94a3b8;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ display: block;
+ margin-bottom: 0.15rem;
+}
+
+.card-meta .themes-label {
+ color: var(--text);
+ font-size: 20px;
+ letter-spacing: 0.05em;
+}
+
+.card-meta .line + .line {
+ margin-top: 0.35rem;
+}
+
+.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;
+ }
+}
+
+/* Hide hover preview on narrow screens to avoid covering content */
+@media (max-width: 900px) {
+ .card-hover {
+ display: none !important;
+ }
+}
+
+/* =============================================================================
+ THEME BADGES (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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: 0.5px;
+}
+
+.theme-synergies {
+ font-size: 11px;
+ opacity: 0.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, 0.25);
+}
+
+.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);
+}
+
+.theme-detail-card h3 {
+ margin-top: 0;
+ margin-bottom: 0.4rem;
+}
+
+.theme-detail-card .desc {
+ margin-top: 0;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.theme-detail-card h4 {
+ margin-bottom: 0.35rem;
+ margin-top: 0.85rem;
+ font-size: 13px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.85;
+}
+
+.breadcrumb {
+ font-size: 12px;
+ margin-bottom: 0.4rem;
+}
+
+/* =============================================================================
+ HOVER CARD PANEL (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+/* 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, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset;
+}
+
+#hover-card-panel.is-payoff .hcp-img {
+ border-color: var(--accent, #38bdf8);
+}
+
+/* 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, 0.6);
+ border-radius: 9999px;
+ padding: 3px 10px;
+ font-size: 13px;
+ margin-right: 6px;
+ margin-top: 4px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+}
+
+/* Mobile hover panel */
+#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: 0.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, 0.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, 0.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: 0.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, 0.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 0.22s ease;
+}
+
+/* =============================================================================
+ DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+/* 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, 0.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: 0.92;
+ backdrop-filter: blur(3px);
+}
+
+.dfc-toggle:hover,
+.dfc-toggle:focus {
+ opacity: 1;
+ box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.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, 0.85);
+}
+
+.dfc-toggle[data-face='front'] {
+ background: rgba(15, 23, 42, 0.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, 0.3);
+}
+
+.list-row .dfc-toggle[data-face='front'] {
+ background: rgba(56, 189, 248, 0.2);
+}
+
+/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */
+
+/* =============================================================================
+ SITE FOOTER (Moved from base.html 2025-10-21)
+ ============================================================================= */
+
+.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;
+}
+
+/* =============================================================================
+ THEME PREVIEW FRAGMENT (themes/preview_fragment.html)
+ ============================================================================= */
+
+/* Preview header */
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.preview-header h3 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.preview-header .btn {
+ font-size: 12px;
+ line-height: 1;
+}
+
+/* Preview controls */
+.preview-controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ margin: 0.5rem 0 0.75rem;
+ font-size: 11px;
+}
+
+.preview-controls label {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.preview-controls .help-icon {
+ opacity: 0.55;
+ font-size: 10px;
+ cursor: help;
+}
+
+.preview-controls #preview-status {
+ opacity: 0.65;
+}
+
+/* Preview rationale */
+.preview-rationale {
+ margin: 0.25rem 0 0.85rem;
+ font-size: 11px;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+}
+
+.preview-rationale summary {
+ cursor: pointer;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+}
+
+.preview-rationale-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ margin-top: 0.4rem;
+}
+
+.preview-rationale-controls .btn {
+ font-size: 10px;
+ padding: 4px 8px;
+}
+
+.preview-rationale-controls #hover-compact-indicator {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+.preview-rationale ul {
+ margin: 0.5rem 0 0 0.9rem;
+ padding: 0;
+ list-style: disc;
+ line-height: 1.35;
+}
+
+.preview-rationale li .detail {
+ opacity: 0.75;
+}
+
+.preview-rationale li .instances {
+ opacity: 0.65;
+}
+
+/* Two column layout */
+.preview-two-col {
+ display: grid;
+ grid-template-columns: 1fr 480px;
+ gap: 1.25rem;
+ align-items: start;
+ position: relative;
+}
+
+.preview-col-divider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: calc(100% - 480px - 0.75rem);
+ width: 1px;
+ background: var(--border);
+ opacity: 0.55;
+}
+
+/* Section headers */
+.preview-section-header {
+ margin: 0.25rem 0 0.5rem;
+ font-size: 13px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.8;
+}
+
+.preview-section-hr {
+ border: 0;
+ border-top: 1px solid var(--border);
+ margin: 0.35rem 0 0.6rem;
+}
+
+/* Cards flow layout */
+.cards-flow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+/* Group separators */
+.group-separator {
+ flex-basis: 100%;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ opacity: 0.65;
+ margin-top: 0.25rem;
+}
+
+.group-separator.mt-larger {
+ margin-top: 0.5rem;
+}
+
+/* Card sample */
+.card-sample {
+ width: 230px;
+}
+
+.card-sample .thumb-wrap {
+ position: relative;
+}
+
+.card-sample img.card-thumb {
+ filter: blur(4px);
+ transition: filter 0.35s ease;
+ background: linear-gradient(145deg, #0b0d12, #111b29);
+}
+
+.card-sample img.card-thumb[data-loaded] {
+ filter: blur(0);
+}
+
+/* Card badges */
+.dup-badge {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ background: #4b5563;
+ color: #fff;
+ font-size: 10px;
+ padding: 2px 5px;
+ border-radius: 10px;
+}
+
+.pin-btn {
+ 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;
+}
+
+/* Card metadata */
+.card-sample .meta {
+ font-size: 12px;
+ margin-top: 2px;
+}
+
+.card-sample .ci-ribbon {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 2px;
+ min-height: 10px;
+}
+
+.card-sample .nm {
+ font-weight: 600;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.card-sample .mana-line {
+ min-height: 14px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ font-size: 10px;
+}
+
+.card-sample .rarity-badge {
+ font-size: 9px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ opacity: 0.7;
+}
+
+.card-sample .role {
+ opacity: 0.75;
+ font-size: 11px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px;
+}
+
+.card-sample .reasons {
+ font-size: 9px;
+ opacity: 0.55;
+ line-height: 1.15;
+}
+
+/* Synthetic card */
+.card-sample.synthetic {
+ border: 1px dashed var(--border);
+ padding: 8px;
+ border-radius: 10px;
+ background: var(--panel-alt);
+}
+
+.card-sample.synthetic .name {
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.card-sample.synthetic .roles {
+ font-size: 11px;
+ opacity: 0.8;
+}
+
+.card-sample.synthetic .reasons-text {
+ font-size: 10px;
+ margin-top: 2px;
+ opacity: 0.6;
+ line-height: 1.15;
+}
+
+/* Spacer */
+.full-width-spacer {
+ flex-basis: 100%;
+ height: 0;
+}
+
+/* Commander grid */
+.commander-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ gap: 1rem;
+}
+
+.commander-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.commander-name {
+ font-size: 13px;
+ text-align: center;
+ line-height: 1.35;
+ font-weight: 600;
+ max-width: 230px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.commander-cell.synergy .commander-name {
+ font-size: 12px;
+ line-height: 1.3;
+ font-weight: 500;
+ opacity: 0.92;
+}
+
+/* Synergy commanders section */
+.synergy-commanders-section {
+ margin-top: 1rem;
+}
+
+.synergy-commanders-header {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-bottom: 0.4rem;
+}
+
+.synergy-commanders-header h5 {
+ margin: 0;
+ font-size: 11px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ opacity: 0.75;
+}
+
+.derived-badge {
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 2px 6px;
+ font-size: 10px;
+ line-height: 1;
+}
+
+/* No commanders message */
+.no-commanders-message {
+ font-size: 11px;
+ opacity: 0.7;
+}
+
+/* Footer help text */
+.preview-help-text {
+ margin-top: 1rem;
+ font-size: 10px;
+ opacity: 0.65;
+ line-height: 1.4;
+}
+
+/* Skeleton loader */
+.preview-skeleton .sk-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.preview-skeleton .sk-bar {
+ height: 16px;
+ background: var(--hover);
+ border-radius: 4px;
+}
+
+.preview-skeleton .sk-bar.title {
+ width: 200px;
+}
+
+.preview-skeleton .sk-bar.close {
+ width: 60px;
+}
+
+.preview-skeleton .sk-cards {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 1rem;
+}
+
+.preview-skeleton .sk-card {
+ width: 230px;
+ height: 327px;
+ background: var(--hover);
+ border-radius: 10px;
+}
+
+/* Responsive */
+@media (max-width: 950px) {
+ .preview-two-col {
+ grid-template-columns: 1fr;
+ }
+
+ .preview-two-col .col-right {
+ order: -1;
+ }
+}
+
+footer.site-footer {
+ flex-shrink: 0;
+}
+
diff --git a/code/web/static/ts/.gitkeep b/code/web/static/ts/.gitkeep
new file mode 100644
index 0000000..badfa20
--- /dev/null
+++ b/code/web/static/ts/.gitkeep
@@ -0,0 +1,2 @@
+# Placeholder for TypeScript source files
+# TypeScript files will be compiled to code/web/static/js/
diff --git a/code/web/templates/base.html b/code/web/templates/base.html
index 72996c3..f79ae00 100644
--- a/code/web/templates/base.html
+++ b/code/web/templates/base.html
@@ -39,6 +39,7 @@
window.__telemetryEndpoint = '/telemetry/events';
+
+
+
+
\ No newline at end of file
diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html
index cc5277c..7afd820 100644
--- a/code/web/templates/build/_new_deck_tags.html
+++ b/code/web/templates/build/_new_deck_tags.html
@@ -15,15 +15,15 @@
-
{{ pname }}
+
{{ pname }}
{% 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();
})();
diff --git a/code/web/templates/build/_partner_controls.html b/code/web/templates/build/_partner_controls.html
index 202bf7d..3de6a96 100644
--- a/code/web/templates/build/_partner_controls.html
+++ b/code/web/templates/build/_partner_controls.html
@@ -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);
diff --git a/code/web/templates/build/_step1.html b/code/web/templates/build/_step1.html
index 65e61b8..0f54ecd 100644
--- a/code/web/templates/build/_step1.html
+++ b/code/web/templates/build/_step1.html
@@ -39,7 +39,7 @@
@@ -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) %}
-
+
diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html
index 6ad2ef7..0186eaa 100644
--- a/code/web/templates/build/_step2.html
+++ b/code/web/templates/build/_step2.html
@@ -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) %}
-
+
{% 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 %}
{% endif %}
{% if partner_name_base %}
-
{% else %}
diff --git a/code/web/templates/build/_step3.html b/code/web/templates/build/_step3.html
index 8231e5b..95e7a39 100644
--- a/code/web/templates/build/_step3.html
+++ b/code/web/templates/build/_step3.html
@@ -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) %}
-
+
diff --git a/code/web/templates/build/_step3_skeleton.html b/code/web/templates/build/_step3_skeleton.html
new file mode 100644
index 0000000..02f6a82
--- /dev/null
+++ b/code/web/templates/build/_step3_skeleton.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
Automating choices...
+
{{ automation_message }}
+
+
+ {# Hidden form that auto-submits with defaults #}
+
+
+
+
+
+
diff --git a/code/web/templates/build/_step4.html b/code/web/templates/build/_step4.html
index 47b986f..ca989b5 100644
--- a/code/web/templates/build/_step4.html
+++ b/code/web/templates/build/_step4.html
@@ -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) %}
-
+
diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html
index b1d4b88..58b7237 100644
--- a/code/web/templates/build/_step5.html
+++ b/code/web/templates/build/_step5.html
@@ -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 %}>
-
-
+
-
+
{{ partner_role_label }}:
{{ partner_secondary_name }}
-