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..2351a17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,14 +9,56 @@ 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
+- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates
+ - Validates Jinja2 syntax across all templates
+ - Checks HTML structure (balanced tags, unique IDs, proper attributes)
+ - Basic accessibility validation (alt text, form labels, button types)
+ - Regression prevention thresholds to maintain code quality
+- **Code Quality Tools**: Enhanced development tooling for maintainability
+ - Automated utilities for code cleanup
+ - Improved type checking configuration
+- **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
+- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
+ - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
+ - Created shared type definitions for state management, telemetry, HTMX, and UI components
+ - Integrated TypeScript compilation into build process (`npm run build:ts`)
+ - Compiled JavaScript output in `code/web/static/js/` directory
+ - Docker build automatically compiles TypeScript during image creation
+
+### Changed
+- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code)
+- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules
+ - Created `cardHover.ts` for unified hover panel functionality
+ - Created `cardImages.ts` for card image loading with automatic retry fallbacks
+ - Reduced inline script size in base template for better maintainability
+- **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
+- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
+ - Light mode: Darker text for improved readability, warm earth tone color palette
+ - Dark mode: Refined contrast for better visual hierarchy
+ - High-contrast mode: Optimized for maximum accessibility
+ - Consistent hover states across all interactive elements
+ - Improved visibility of form inputs and controls
+- **JavaScript Modernization**: Updated to modern JavaScript patterns
+ - Converted `var` declarations to `const`/`let`
+ - Added TypeScript type annotations for better IDE support and error catching
+ - Consolidated event handlers and utility functions
+- **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,18 +69,46 @@ 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_
+- Migrated 5 templates to new component system (home, 404, 500, setup, commanders)
+- **Type Checking Configuration**: Improved Python code quality tooling
+ - Configured type checker for better error detection
+ - Optimized linting rules for development workflow
+
+### Fixed
+- **Template Quality**: Resolved HTML structure issues found by validation tests
+ - Fixed duplicate ID attributes in build wizard and theme picker templates
+ - Removed erroneous block tags from component documentation
+ - Corrected template structure for HTMX fragments
+- **Code Quality**: Resolved type checking warnings and improved code maintainability
+ - Fixed type annotation inconsistencies
+ - Cleaned up redundant code quality suppressions
+ - Corrected configuration conflicts
### Removed
_None_
-### Fixed
-_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
+- TypeScript compilation produces optimized JavaScript
+
+### For Users
+- Faster card image loading with optional caching
+- Cleaner, more consistent web UI design
+- Improved page load performance
+- More reliable JavaScript behavior
### 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..f03d5c5 100644
--- a/RELEASE_NOTES_TEMPLATE.md
+++ b/RELEASE_NOTES_TEMPLATE.md
@@ -3,36 +3,106 @@
## [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, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability.
### 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`
+- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality
+ - Validates Jinja2 syntax and structure
+ - Checks for common HTML issues (duplicate IDs, balanced tags)
+ - Basic accessibility validation
+ - Prevents regression in template quality
+- **Code Quality Tools**: Enhanced development tooling for maintainability
+ - Automated utilities for code cleanup
+ - Improved type checking configuration
+- **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
+- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
+ - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
+ - Created shared type definitions for state management, telemetry, HTMX, and UI components
+ - Integrated TypeScript compilation into build process (`npm run build:ts`)
+ - Compiled JavaScript output in `code/web/static/js/` directory
+ - Docker build automatically compiles TypeScript during image creation
### Changed
-_None_
+- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code)
+- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules
+ - Created `cardHover.ts` for unified hover panel functionality
+ - Created `cardImages.ts` for card image loading with automatic retry fallbacks
+ - Reduced inline script size in base template for better maintainability
+- **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
+- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
+ - Light mode: Darker text for improved readability, warm earth tone color palette
+ - Dark mode: Refined contrast for better visual hierarchy
+ - High-contrast mode: Optimized for maximum accessibility
+ - Consistent hover states across all interactive elements
+ - Improved visibility of form inputs and controls
+- **JavaScript Modernization**: Updated to modern JavaScript patterns
+ - Converted `var` declarations to `const`/`let`
+ - Added TypeScript type annotations for better IDE support and error catching
+ - Consolidated event handlers and utility functions
+- **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
+- **Type Checking Configuration**: Improved Python code quality tooling
+ - Configured type checker for better error detection
+ - Optimized linting rules for development workflow
+- **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
+ - Color-coded synergy scores in preview (green=high, red=low)
+ - Partner commander support with combined color identity
+ - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands)
+ - Export synergy deck with full metadata (CSV, TXT, JSON files)
+- `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
### Removed
_None_
### Fixed
-_None_
+- **Template Quality**: Resolved HTML structure issues
+ - Fixed duplicate ID attributes in templates
+ - Removed erroneous template block tags
+ - Corrected structure for HTMX fragments
+- **Code Quality**: Resolved type checking warnings and improved code maintainability
+ - Fixed type annotation inconsistencies
+ - Cleaned up redundant code quality suppressions
+ - Corrected configuration conflicts
### 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
+- TypeScript compilation produces optimized JavaScript
### For Users
-_No changes yet_
+- Faster card image loading with optional caching
+- Cleaner, more consistent web UI design
+- Improved page load performance
+- More reliable JavaScript behavior
### Deprecated
_None_
diff --git a/code/deck_builder/__init__.py b/code/deck_builder/__init__.py
index c992bac..9540709 100644
--- a/code/deck_builder/__init__.py
+++ b/code/deck_builder/__init__.py
@@ -4,6 +4,6 @@ __all__ = ['DeckBuilder']
def __getattr__(name):
# Lazy-load DeckBuilder to avoid side effects during import of submodules
if name == 'DeckBuilder':
- from .builder import DeckBuilder # type: ignore
+ from .builder import DeckBuilder
return DeckBuilder
raise AttributeError(name)
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.py b/code/deck_builder/builder.py
index 50d899e..a7eadd7 100644
--- a/code/deck_builder/builder.py
+++ b/code/deck_builder/builder.py
@@ -95,7 +95,7 @@ class DeckBuilder(
# If a seed was assigned pre-init, use it
if self.seed is not None:
# Import here to avoid any heavy import cycles at module import time
- from random_util import set_seed as _set_seed # type: ignore
+ from random_util import set_seed as _set_seed
self._rng = _set_seed(int(self.seed))
else:
self._rng = random.Random()
@@ -107,7 +107,7 @@ class DeckBuilder(
def set_seed(self, seed: int | str) -> None:
"""Set deterministic seed for this builder and reset its RNG instance."""
try:
- from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore
+ from random_util import derive_seed_from_string as _derive, set_seed as _set_seed
s = _derive(seed)
self.seed = int(s)
self._rng = _set_seed(s)
@@ -215,7 +215,7 @@ class DeckBuilder(
try:
# Compute a quick compliance snapshot here to hint at upcoming enforcement
if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False):
- from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore
+ from deck_builder.brackets_compliance import evaluate_deck as _eval
bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower()
commander = getattr(self, 'commander_name', None)
snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key)
@@ -240,15 +240,15 @@ class DeckBuilder(
csv_path = self.export_decklist_csv()
# Persist CSV path immediately (before any later potential exceptions)
try:
- self.last_csv_path = csv_path # type: ignore[attr-defined]
+ self.last_csv_path = csv_path
except Exception:
pass
try:
import os as _os
base, _ext = _os.path.splitext(_os.path.basename(csv_path))
- txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
+ txt_path = self.export_decklist_text(filename=base + '.txt')
try:
- self.last_txt_path = txt_path # type: ignore[attr-defined]
+ self.last_txt_path = txt_path
except Exception:
pass
# Display the text file contents for easy copy/paste to online deck builders
@@ -256,18 +256,18 @@ class DeckBuilder(
# Compute bracket compliance and save a JSON report alongside exports
try:
if hasattr(self, 'compute_and_print_compliance'):
- report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
+ report0 = self.compute_and_print_compliance(base_stem=base)
# If non-compliant and interactive, offer enforcement now
try:
if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False):
- from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore
+ from deck_builder.phases.phase6_reporting import ReportingMixin as _RM
if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'):
self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.")
try:
_ = self.input_func("")
except Exception:
pass
- self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined]
+ self.enforce_and_reexport(base_stem=base, mode='prompt')
except Exception:
pass
except Exception:
@@ -295,12 +295,12 @@ class DeckBuilder(
cfg_dir = 'config'
if cfg_dir:
_os.makedirs(cfg_dir, exist_ok=True)
- self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined]
+ self.export_run_config_json(directory=cfg_dir, filename=base + '.json')
if cfg_path_env:
cfg_dir2 = _os.path.dirname(cfg_path_env) or '.'
cfg_name2 = _os.path.basename(cfg_path_env)
_os.makedirs(cfg_dir2, exist_ok=True)
- self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined]
+ self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2)
except Exception:
pass
except Exception:
@@ -308,8 +308,8 @@ class DeckBuilder(
else:
# Mark suppression so random flow knows nothing was exported yet
try:
- self.last_csv_path = None # type: ignore[attr-defined]
- self.last_txt_path = None # type: ignore[attr-defined]
+ self.last_csv_path = None
+ self.last_txt_path = None
except Exception:
pass
# If owned-only and deck not complete, print a note
@@ -624,8 +624,8 @@ class DeckBuilder(
try:
rec.card_library = rec_subset
# Export CSV and TXT with suffix
- rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) # type: ignore[attr-defined]
- rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) # type: ignore[attr-defined]
+ rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True)
+ rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True)
finally:
rec.card_library = original_lib
# Notify user succinctly
@@ -1843,7 +1843,7 @@ class DeckBuilder(
from deck_builder import builder_constants as bc
from settings import MULTIPLE_COPY_CARDS
except Exception:
- MULTIPLE_COPY_CARDS = [] # type: ignore
+ MULTIPLE_COPY_CARDS = []
is_land = 'land' in str(card_type or entry.get('Card Type','')).lower()
is_basic = False
try:
@@ -2353,7 +2353,7 @@ class DeckBuilder(
rng = getattr(self, 'rng', None)
try:
if rng:
- rng.shuffle(bucket_keys) # type: ignore
+ rng.shuffle(bucket_keys)
else:
random.shuffle(bucket_keys)
except Exception:
diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py
index dd664d3..02e2054 100644
--- a/code/deck_builder/builder_constants.py
+++ b/code/deck_builder/builder_constants.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any
+from typing import Dict, List, Final, Tuple, Union, Callable, Any
from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified
from path_util import csv_dir
import pandas as pd
@@ -21,7 +21,7 @@ DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv"
DECK_DIRECTORY = '../deck_files'
# M4: Deprecated - Parquet handles types natively (no converters needed)
-COMMANDER_CONVERTERS: Final[Dict[str, str]] = {
+COMMANDER_CONVERTERS: Final[Dict[str, Any]] = {
'themeTags': ast.literal_eval,
'creatureTypes': ast.literal_eval,
'roleTags': ast.literal_eval,
@@ -140,18 +140,18 @@ OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
}
# Card category validation rules
-CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
+CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'power': {'type': ('str', 'int', 'float'), 'required': True},
'toughness': {'type': ('str', 'int', 'float'), 'required': True},
'creatureTypes': {'type': 'list', 'required': True}
}
-SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
+SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'manaCost': {'type': 'str', 'required': True},
'text': {'type': 'str', 'required': True}
}
-LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
+LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'type': {'type': ('str', 'object'), 'required': True},
'text': {'type': ('str', 'object'), 'required': False}
}
@@ -526,7 +526,7 @@ CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations
CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch
# CSV validation configuration
-CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = {
+CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = {
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
@@ -602,12 +602,12 @@ GAME_CHANGERS: Final[List[str]] = [
# - color_identity: list[str] of required color letters (subset must be in commander CI)
# - printed_cap: int | None (None means no printed cap)
# - exclusive_group: str | None (at most one from the same group)
-# - triggers: { tags_any: list[str], tags_all: list[str] }
+# - triggers: { tagsAny: list[str], tags_all: list[str] }
# - default_count: int (default 25)
# - rec_window: tuple[int,int] (recommendation window)
# - thrumming_stone_synergy: bool
# - type_hint: 'creature' | 'noncreature'
-MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
+MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = {
'cid_timeless_artificer': {
'id': 'cid_timeless_artificer',
'name': 'Cid, Timeless Artificer',
@@ -615,7 +615,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['artificer kindred', 'hero kindred', 'artifacts matter'],
+ 'tagsAny': ['artificer kindred', 'hero kindred', 'artifacts matter'],
'tags_all': []
},
'default_count': 25,
@@ -630,7 +630,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'],
+ 'tagsAny': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'],
'tags_all': []
},
'default_count': 25,
@@ -645,7 +645,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['rabbit kindred','tokens matter','aggro'],
+ 'tagsAny': ['rabbit kindred','tokens matter','aggro'],
'tags_all': []
},
'default_count': 25,
@@ -660,7 +660,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'],
+ 'tagsAny': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'],
'tags_all': []
},
'default_count': 25,
@@ -675,7 +675,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': 'rats',
'triggers': {
- 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
+ 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
'tags_all': []
},
'default_count': 25,
@@ -690,7 +690,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': 'rats',
'triggers': {
- 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
+ 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'],
'tags_all': []
},
'default_count': 25,
@@ -705,7 +705,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': 7,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'],
+ 'tagsAny': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'],
'tags_all': []
},
'default_count': 7,
@@ -720,7 +720,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['mill','advisor kindred','control','defenders','walls','draw-go'],
+ 'tagsAny': ['mill','advisor kindred','control','defenders','walls','draw-go'],
'tags_all': []
},
'default_count': 25,
@@ -735,7 +735,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'],
+ 'tagsAny': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'],
'tags_all': []
},
'default_count': 25,
@@ -750,7 +750,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': 9,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'],
+ 'tagsAny': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'],
'tags_all': []
},
'default_count': 9,
@@ -765,7 +765,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['bird kindred','aggro'],
+ 'tagsAny': ['bird kindred','aggro'],
'tags_all': []
},
'default_count': 25,
@@ -780,7 +780,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = {
'printed_cap': None,
'exclusive_group': None,
'triggers': {
- 'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'],
+ 'tagsAny': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'],
'tags_all': []
},
'default_count': 25,
@@ -956,3 +956,4 @@ def get_backgrounds(df: pd.DataFrame) -> pd.DataFrame:
if 'isBackground' not in df.columns:
return pd.DataFrame()
return df[df['isBackground'] == True].copy() # noqa: E712
+
diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py
index 36ab3fe..a47101e 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()
@@ -316,7 +425,7 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
matrix: Dict[str, Dict[str, int]] = {}
lookup = {}
if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns:
- for _, r in full_df.iterrows(): # type: ignore[attr-defined]
+ for _, r in full_df.iterrows():
nm = str(r.get('name', ''))
if nm and nm not in lookup:
lookup[nm] = r
@@ -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
@@ -729,7 +850,7 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i
out: list[tuple[int,str,str,str]] = []
if df is None or getattr(df, 'empty', True):
return out
- for _, row in df.iterrows(): # type: ignore[attr-defined]
+ for _, row in df.iterrows():
try:
name = str(row.get('name',''))
if not name or name in already or name in basics:
@@ -993,7 +1114,7 @@ def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
# ---------------------------------------------------------------------------
# Tag-driven land suggestion helpers
# ---------------------------------------------------------------------------
-def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override]
+def build_tag_driven_suggestions(builder) -> list[dict]:
"""Return a list of suggestion dicts based on selected commander tags.
Each dict fields:
@@ -1081,7 +1202,7 @@ def color_balance_addition_candidates(builder, target_color: str, combined_df) -
return []
existing = set(builder.card_library.keys())
out: list[tuple[str, int]] = []
- for _, row in combined_df.iterrows(): # type: ignore[attr-defined]
+ for _, row in combined_df.iterrows():
name = str(row.get('name', ''))
if not name or name in existing or any(name == o[0] for o in out):
continue
diff --git a/code/deck_builder/enforcement.py b/code/deck_builder/enforcement.py
index 0f0ef17..ecc9395 100644
--- a/code/deck_builder/enforcement.py
+++ b/code/deck_builder/enforcement.py
@@ -88,12 +88,12 @@ def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]:
# Sort by edhrecRank then manaValue
try:
from . import builder_utils as bu
- sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined]
+ sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"])
# Prefer-owned bias
if getattr(builder, "prefer_owned", False):
owned = getattr(builder, "owned_card_names", None)
if owned:
- sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined]
+ sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned})
except Exception:
sorted_df = pool
@@ -363,7 +363,7 @@ def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict:
break
# Rank candidates: break the most combos first; break ties by worst desirability
cand_names = list(freq.keys())
- cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type]
+ cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False)
removed_any = False
for nm in cand_names:
if nm in blocked:
diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py
index f5808bc..4ec59fc 100644
--- a/code/deck_builder/partner_selection.py
+++ b/code/deck_builder/partner_selection.py
@@ -17,7 +17,7 @@ from logging_util import get_logger
logger = get_logger(__name__)
try: # Optional pandas import for type checking without heavy dependency at runtime.
- import pandas as _pd # type: ignore
+ import pandas as _pd
except Exception: # pragma: no cover - tests provide DataFrame-like objects.
_pd = None # type: ignore
@@ -267,7 +267,7 @@ def _find_commander_row(df: Any, name: str | None):
if not target:
return None
- if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore
+ if _pd is not None and isinstance(df, _pd.DataFrame):
columns = [col for col in ("name", "faceName") if col in df.columns]
for col in columns:
series = df[col].astype(str).str.casefold()
@@ -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/phase0_core.py b/code/deck_builder/phases/phase0_core.py
index d464204..a23f96c 100644
--- a/code/deck_builder/phases/phase0_core.py
+++ b/code/deck_builder/phases/phase0_core.py
@@ -25,11 +25,11 @@ No behavior change intended.
# Attempt to use a fast fuzzy library; fall back gracefully
try:
- from rapidfuzz import process as rf_process, fuzz as rf_fuzz # type: ignore
+ from rapidfuzz import process as rf_process, fuzz as rf_fuzz
_FUZZ_BACKEND = "rapidfuzz"
except ImportError: # pragma: no cover - environment dependent
try:
- from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz # type: ignore
+ from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz
_FUZZ_BACKEND = "fuzzywuzzy"
except ImportError: # pragma: no cover
_FUZZ_BACKEND = "difflib"
diff --git a/code/deck_builder/phases/phase1_commander.py b/code/deck_builder/phases/phase1_commander.py
index 98f196c..6cdead5 100644
--- a/code/deck_builder/phases/phase1_commander.py
+++ b/code/deck_builder/phases/phase1_commander.py
@@ -68,7 +68,7 @@ class CommanderSelectionMixin:
out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
return ' '.join(out_words)
- def choose_commander(self) -> str: # type: ignore[override]
+ def choose_commander(self) -> str:
df = self.load_commander_data()
names = df["name"].tolist()
while True:
@@ -113,7 +113,7 @@ class CommanderSelectionMixin:
continue
query = self._normalize_commander_query(choice) # treat as new (normalized) query
- def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
+ def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool:
row = df[df["name"] == name].iloc[0]
pretty = self._format_commander_pretty(row)
self.output_func("\n" + pretty)
@@ -126,7 +126,7 @@ class CommanderSelectionMixin:
return False
self.output_func("Please enter y or n.")
- def _apply_commander_selection(self, row: pd.Series): # type: ignore[override]
+ def _apply_commander_selection(self, row: pd.Series):
self.commander_name = row["name"]
self.commander_row = row
tags_value = row.get("themeTags", [])
@@ -136,7 +136,7 @@ class CommanderSelectionMixin:
# ---------------------------
# Tag Prioritization
# ---------------------------
- def select_commander_tags(self) -> List[str]: # type: ignore[override]
+ def select_commander_tags(self) -> List[str]:
if not self.commander_name:
self.output_func("No commander chosen yet. Selecting commander first...")
self.choose_commander()
@@ -173,7 +173,7 @@ class CommanderSelectionMixin:
self._update_commander_dict_with_selected_tags()
return self.selected_tags
- def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override]
+ def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]:
while True:
self.output_func("\nCurrent options:")
for i, t in enumerate(available, 1):
@@ -192,7 +192,7 @@ class CommanderSelectionMixin:
return matches[0]
self.output_func("Invalid selection. Try again.")
- def _update_commander_dict_with_selected_tags(self): # type: ignore[override]
+ def _update_commander_dict_with_selected_tags(self):
if not self.commander_dict and self.commander_row is not None:
self._initialize_commander_dict(self.commander_row)
if not self.commander_dict:
@@ -205,7 +205,7 @@ class CommanderSelectionMixin:
# ---------------------------
# Power Bracket Selection
# ---------------------------
- def select_power_bracket(self) -> BracketDefinition: # type: ignore[override]
+ def select_power_bracket(self) -> BracketDefinition:
if self.bracket_definition:
return self.bracket_definition
self.output_func("\nChoose Deck Power Bracket:")
@@ -229,14 +229,14 @@ class CommanderSelectionMixin:
return match
self.output_func("Invalid input. Type 1-5 or 'info'.")
- def _print_bracket_details(self): # type: ignore[override]
+ def _print_bracket_details(self):
self.output_func("\nBracket Details:")
for bd in BRACKET_DEFINITIONS:
self.output_func(f"\n[{bd.level}] {bd.name}")
self.output_func(bd.long_desc)
self.output_func(self._format_limits(bd.limits))
- def _print_selected_bracket_summary(self): # type: ignore[override]
+ def _print_selected_bracket_summary(self):
self.output_func("\nBracket Constraints:")
if self.bracket_limits:
self.output_func(self._format_limits(self.bracket_limits))
diff --git a/code/deck_builder/phases/phase2_lands_basics.py b/code/deck_builder/phases/phase2_lands_basics.py
index ccf0a3f..36b1586 100644
--- a/code/deck_builder/phases/phase2_lands_basics.py
+++ b/code/deck_builder/phases/phase2_lands_basics.py
@@ -22,7 +22,7 @@ Expected attributes / methods on the host DeckBuilder:
class LandBasicsMixin:
- def add_basic_lands(self): # type: ignore[override]
+ def add_basic_lands(self):
"""Add basic (or snow basic) lands based on color identity.
Logic:
@@ -71,8 +71,8 @@ class LandBasicsMixin:
basic_min: Optional[int] = None
land_total: Optional[int] = None
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
- basic_min = self.ideal_counts.get('basic_lands') # type: ignore[attr-defined]
- land_total = self.ideal_counts.get('lands') # type: ignore[attr-defined]
+ basic_min = self.ideal_counts.get('basic_lands')
+ land_total = self.ideal_counts.get('lands')
if basic_min is None:
basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if land_total is None:
@@ -136,7 +136,7 @@ class LandBasicsMixin:
self.output_func(f" {name.ljust(width)} : {cnt}")
self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})")
- def run_land_step1(self): # type: ignore[override]
+ def run_land_step1(self):
"""Public wrapper to execute land building step 1 (basics)."""
self.add_basic_lands()
try:
diff --git a/code/deck_builder/phases/phase2_lands_duals.py b/code/deck_builder/phases/phase2_lands_duals.py
index 7db15f2..713c1f4 100644
--- a/code/deck_builder/phases/phase2_lands_duals.py
+++ b/code/deck_builder/phases/phase2_lands_duals.py
@@ -21,7 +21,7 @@ Host DeckBuilder must provide:
"""
class LandDualsMixin:
- def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override]
+ def add_dual_lands(self, requested_count: int | None = None):
"""Add two-color 'typed' dual lands based on color identity."""
if not getattr(self, 'files_to_load', []):
try:
@@ -117,10 +117,10 @@ class LandDualsMixin:
pair_buckets[key] = names
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if getattr(self, 'ideal_counts', None):
- min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined]
- basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined]
+ min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
+ basic_floor = self._basic_floor(min_basic_cfg)
default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6)
- remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
+ remaining_capacity = max(0, land_target - self._current_land_count())
effective_default = min(default_dual_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool))
desired = effective_default if requested_count is None else max(0, int(requested_count))
if desired == 0:
@@ -129,14 +129,14 @@ class LandDualsMixin:
if remaining_capacity == 0 and desired > 0:
slots_needed = desired
freed_slots = 0
- while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: # type: ignore[attr-defined]
- target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined]
- if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined]
+ while freed_slots < slots_needed and self._count_basic_lands() > basic_floor:
+ target_basic = self._choose_basic_to_trim()
+ if not target_basic or not self._decrement_card(target_basic):
break
freed_slots += 1
if freed_slots == 0:
desired = 0
- remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
+ remaining_capacity = max(0, land_target - self._current_land_count())
desired = min(desired, remaining_capacity, len(pool))
if desired <= 0:
self.output_func("Dual Lands: No capacity after trimming; skipping.")
@@ -146,7 +146,7 @@ class LandDualsMixin:
rng = getattr(self, 'rng', None)
try:
if rng:
- rng.shuffle(bucket_keys) # type: ignore
+ rng.shuffle(bucket_keys)
else:
random.shuffle(bucket_keys)
except Exception:
@@ -171,7 +171,7 @@ class LandDualsMixin:
break
added: List[str] = []
for name in chosen:
- if self._current_land_count() >= land_target: # type: ignore[attr-defined]
+ if self._current_land_count() >= land_target:
break
# Determine sub_role as concatenated color pair for traceability
try:
@@ -198,7 +198,7 @@ class LandDualsMixin:
role='dual',
sub_role=sub_role,
added_by='lands_step5'
- ) # type: ignore[attr-defined]
+ )
added.append(name)
self.output_func("\nDual Lands Added (Step 5):")
if not added:
@@ -207,11 +207,11 @@ class LandDualsMixin:
width = max(len(n) for n in added)
for n in added:
self.output_func(f" {n.ljust(width)} : 1")
- self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
+ self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
- def run_land_step5(self, requested_count: int | None = None): # type: ignore[override]
+ def run_land_step5(self, requested_count: int | None = None):
self.add_dual_lands(requested_count=requested_count)
- self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined]
+ self._enforce_land_cap(step_label="Duals (Step 5)")
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '5')
diff --git a/code/deck_builder/phases/phase2_lands_fetch.py b/code/deck_builder/phases/phase2_lands_fetch.py
index 57de480..4dcf54b 100644
--- a/code/deck_builder/phases/phase2_lands_fetch.py
+++ b/code/deck_builder/phases/phase2_lands_fetch.py
@@ -19,7 +19,7 @@ Host DeckBuilder must supply:
"""
class LandFetchMixin:
- def add_fetch_lands(self, requested_count: int | None = None): # type: ignore[override]
+ def add_fetch_lands(self, requested_count: int | None = None):
"""Add fetch lands (color-specific + generic) respecting land target."""
if not getattr(self, 'files_to_load', []):
try:
@@ -28,8 +28,8 @@ class LandFetchMixin:
except Exception as e: # pragma: no cover - defensive
self.output_func(f"Cannot add fetch lands until color identity resolved: {e}")
return
- land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) # type: ignore[attr-defined]
- current = self._current_land_count() # type: ignore[attr-defined]
+ land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35)
+ current = self._current_land_count()
color_order = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']]
color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {})
candidates: List[str] = []
@@ -56,7 +56,7 @@ class LandFetchMixin:
self.output_func("\nAdd Fetch Lands (Step 4):")
self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.")
prompt = f"Enter desired number of fetch lands (default: {effective_default}):"
- desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) # type: ignore[attr-defined]
+ desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20)
else:
desired = max(0, int(requested_count))
if desired > remaining_fetch_slots:
@@ -70,20 +70,20 @@ class LandFetchMixin:
if remaining_capacity == 0 and desired > 0:
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if getattr(self, 'ideal_counts', None):
- min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined]
- floor_basics = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined]
+ min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
+ floor_basics = self._basic_floor(min_basic_cfg)
slots_needed = desired
- while slots_needed > 0 and self._count_basic_lands() > floor_basics: # type: ignore[attr-defined]
- target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined]
- if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined]
+ while slots_needed > 0 and self._count_basic_lands() > floor_basics:
+ target_basic = self._choose_basic_to_trim()
+ if not target_basic or not self._decrement_card(target_basic):
break
slots_needed -= 1
- remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
+ remaining_capacity = max(0, land_target - self._current_land_count())
if remaining_capacity > 0 and slots_needed == 0:
break
if slots_needed > 0 and remaining_capacity == 0:
desired -= slots_needed
- remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
+ remaining_capacity = max(0, land_target - self._current_land_count())
desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots)
if desired <= 0:
self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.")
@@ -101,7 +101,7 @@ class LandFetchMixin:
if k >= len(pool):
return pool.copy()
try:
- return (rng.sample if rng else random.sample)(pool, k) # type: ignore
+ return (rng.sample if rng else random.sample)(pool, k)
except Exception:
return pool[:k]
need = desired
@@ -117,7 +117,7 @@ class LandFetchMixin:
added: List[str] = []
for nm in chosen:
- if self._current_land_count() >= land_target: # type: ignore[attr-defined]
+ if self._current_land_count() >= land_target:
break
note = 'generic' if nm in generic_list else 'color-specific'
self.add_card(
@@ -126,11 +126,11 @@ class LandFetchMixin:
role='fetch',
sub_role=note,
added_by='lands_step4'
- ) # type: ignore[attr-defined]
+ )
added.append(nm)
# Record actual number of fetch lands added for export/replay context
try:
- setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined]
+ setattr(self, 'fetch_count', len(added))
except Exception:
pass
self.output_func("\nFetch Lands Added (Step 4):")
@@ -141,9 +141,9 @@ class LandFetchMixin:
for n in added:
note = 'generic' if n in generic_list else 'color-specific'
self.output_func(f" {n.ljust(width)} : 1 ({note})")
- self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
+ self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
- def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
+ def run_land_step4(self, requested_count: int | None = None):
"""Public wrapper to add fetch lands.
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
@@ -155,7 +155,7 @@ class LandFetchMixin:
except Exception:
desired = requested_count
self.add_fetch_lands(requested_count=desired)
- self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
+ self._enforce_land_cap(step_label="Fetch (Step 4)")
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '4')
diff --git a/code/deck_builder/phases/phase2_lands_kindred.py b/code/deck_builder/phases/phase2_lands_kindred.py
index bca1827..2b361c7 100644
--- a/code/deck_builder/phases/phase2_lands_kindred.py
+++ b/code/deck_builder/phases/phase2_lands_kindred.py
@@ -20,7 +20,7 @@ Host DeckBuilder must provide:
"""
class LandKindredMixin:
- def add_kindred_lands(self): # type: ignore[override]
+ def add_kindred_lands(self):
"""Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'.
Baseline inclusions on kindred focus:
@@ -41,32 +41,32 @@ class LandKindredMixin:
self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.")
return
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
- land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) # type: ignore[attr-defined]
+ land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35))
else:
land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
- min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined]
- basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined]
+ min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
+ basic_floor = self._basic_floor(min_basic_cfg)
def ensure_capacity() -> bool:
- if self._current_land_count() < land_target: # type: ignore[attr-defined]
+ if self._current_land_count() < land_target:
return True
- if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined]
+ if self._count_basic_lands() <= basic_floor:
return False
- target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined]
+ target_basic = self._choose_basic_to_trim()
if not target_basic:
return False
- if not self._decrement_card(target_basic): # type: ignore[attr-defined]
+ if not self._decrement_card(target_basic):
return False
- return self._current_land_count() < land_target # type: ignore[attr-defined]
+ return self._current_land_count() < land_target
colors = getattr(self, 'color_identity', []) or []
added: List[str] = []
reasons: Dict[str, str] = {}
def try_add(name: str, reason: str):
- if name in self.card_library: # type: ignore[attr-defined]
+ if name in self.card_library:
return
if not ensure_capacity():
return
@@ -77,7 +77,7 @@ class LandKindredMixin:
sub_role='baseline' if reason.startswith('kindred focus') else 'tribe-specific',
added_by='lands_step3',
trigger_tag='Kindred/Tribal'
- ) # type: ignore[attr-defined]
+ )
added.append(name)
reasons[name] = reason
@@ -105,14 +105,14 @@ class LandKindredMixin:
if snapshot is not None and not snapshot.empty and tribe_terms:
dynamic_limit = 5
for tribe in sorted(tribe_terms):
- if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined]
+ if self._current_land_count() >= land_target or dynamic_limit <= 0:
break
tribe_lower = tribe.lower()
matches: List[str] = []
for _, row in snapshot.iterrows():
try:
nm = str(row.get('name', ''))
- if not nm or nm in self.card_library: # type: ignore[attr-defined]
+ if not nm or nm in self.card_library:
continue
tline = str(row.get('type', row.get('type_line', ''))).lower()
if 'land' not in tline:
@@ -125,7 +125,7 @@ class LandKindredMixin:
except Exception:
continue
for nm in matches[:2]:
- if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined]
+ if self._current_land_count() >= land_target or dynamic_limit <= 0:
break
if nm in added or nm in getattr(bc, 'BASIC_LANDS', []):
continue
@@ -139,12 +139,12 @@ class LandKindredMixin:
width = max(len(n) for n in added)
for n in added:
self.output_func(f" {n.ljust(width)} : 1 ({reasons.get(n,'')})")
- self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
+ self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
- def run_land_step3(self): # type: ignore[override]
+ def run_land_step3(self):
"""Public wrapper to add kindred-focused lands."""
self.add_kindred_lands()
- self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined]
+ self._enforce_land_cap(step_label="Kindred (Step 3)")
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '3')
diff --git a/code/deck_builder/phases/phase2_lands_misc.py b/code/deck_builder/phases/phase2_lands_misc.py
index a12ce0d..4d0cbef 100644
--- a/code/deck_builder/phases/phase2_lands_misc.py
+++ b/code/deck_builder/phases/phase2_lands_misc.py
@@ -19,7 +19,7 @@ class LandMiscUtilityMixin:
- Diagnostics & CSV exports
"""
- def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override]
+ def add_misc_utility_lands(self, requested_count: Optional[int] = None):
# --- Initialization & candidate collection ---
if not getattr(self, 'files_to_load', None):
try:
@@ -293,7 +293,7 @@ class LandMiscUtilityMixin:
if getattr(self, 'show_diagnostics', False) and filtered_out:
self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})")
- def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override]
+ def run_land_step7(self, requested_count: Optional[int] = None):
self.add_misc_utility_lands(requested_count=requested_count)
self._enforce_land_cap(step_label="Utility (Step 7)")
self._build_tag_driven_land_suggestions()
@@ -305,12 +305,12 @@ class LandMiscUtilityMixin:
pass
# ---- Tag-driven suggestion helpers (used after Step 7) ----
- def _build_tag_driven_land_suggestions(self): # type: ignore[override]
+ def _build_tag_driven_land_suggestions(self):
suggestions = bu.build_tag_driven_suggestions(self)
if suggestions:
self.suggested_lands_queue.extend(suggestions)
- def _apply_land_suggestions_if_room(self): # type: ignore[override]
+ def _apply_land_suggestions_if_room(self):
if not self.suggested_lands_queue:
return
land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35)
diff --git a/code/deck_builder/phases/phase2_lands_optimize.py b/code/deck_builder/phases/phase2_lands_optimize.py
index c74d411..9c32129 100644
--- a/code/deck_builder/phases/phase2_lands_optimize.py
+++ b/code/deck_builder/phases/phase2_lands_optimize.py
@@ -12,7 +12,7 @@ class LandOptimizationMixin:
Provides optimize_tapped_lands and run_land_step8 (moved from monolithic builder).
"""
- def optimize_tapped_lands(self): # type: ignore[override]
+ def optimize_tapped_lands(self):
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty:
return
@@ -146,7 +146,7 @@ class LandOptimizationMixin:
new_tapped += 1
self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})")
- def run_land_step8(self): # type: ignore[override]
+ def run_land_step8(self):
self.optimize_tapped_lands()
self._enforce_land_cap(step_label="Tapped Opt (Step 8)")
if self.color_source_matrix_baseline is None:
diff --git a/code/deck_builder/phases/phase2_lands_staples.py b/code/deck_builder/phases/phase2_lands_staples.py
index 8d2e21c..159319c 100644
--- a/code/deck_builder/phases/phase2_lands_staples.py
+++ b/code/deck_builder/phases/phase2_lands_staples.py
@@ -27,10 +27,10 @@ class LandStaplesMixin:
# ---------------------------
# Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet)
# ---------------------------
- def _current_land_count(self) -> int: # type: ignore[override]
+ def _current_land_count(self) -> int:
"""Return total number of land cards currently in the library (counts duplicates)."""
total = 0
- for name, entry in self.card_library.items(): # type: ignore[attr-defined]
+ for name, entry in self.card_library.items():
ctype = entry.get('Card Type', '')
if ctype and 'land' in ctype.lower():
total += entry.get('Count', 1)
@@ -47,7 +47,7 @@ class LandStaplesMixin:
continue
return total
- def add_staple_lands(self): # type: ignore[override]
+ def add_staple_lands(self):
"""Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands).
Respects total land target (ideal_counts['lands']). Skips additions once target reached.
@@ -62,25 +62,25 @@ class LandStaplesMixin:
return
land_target = None
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
- land_target = self.ideal_counts.get('lands') # type: ignore[attr-defined]
+ land_target = self.ideal_counts.get('lands')
if land_target is None:
land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35)
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'):
- min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined]
- basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined]
+ min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
+ basic_floor = self._basic_floor(min_basic_cfg)
def ensure_capacity() -> bool:
- if self._current_land_count() < land_target: # type: ignore[attr-defined]
+ if self._current_land_count() < land_target:
return True
- if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined]
+ if self._count_basic_lands() <= basic_floor:
return False
- target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined]
+ target_basic = self._choose_basic_to_trim()
if not target_basic:
return False
- if not self._decrement_card(target_basic): # type: ignore[attr-defined]
+ if not self._decrement_card(target_basic):
return False
- return self._current_land_count() < land_target # type: ignore[attr-defined]
+ return self._current_land_count() < land_target
commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or [])
colors = getattr(self, 'color_identity', []) or []
@@ -102,7 +102,7 @@ class LandStaplesMixin:
if not ensure_capacity():
self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.")
break
- if land_name in self.card_library: # type: ignore[attr-defined]
+ if land_name in self.card_library:
continue
try:
include = cond(list(commander_tags_all), colors, commander_power)
@@ -115,7 +115,7 @@ class LandStaplesMixin:
role='staple',
sub_role='generic-staple',
added_by='lands_step2'
- ) # type: ignore[attr-defined]
+ )
added.append(land_name)
if land_name == 'Command Tower':
reasons[land_name] = f"multi-color ({len(colors)} colors)"
@@ -137,12 +137,12 @@ class LandStaplesMixin:
for n in added:
reason = reasons.get(n, '')
self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}")
- self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
+ self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
- def run_land_step2(self): # type: ignore[override]
+ def run_land_step2(self):
"""Public wrapper for adding generic staple nonbasic lands (excluding kindred)."""
self.add_staple_lands()
- self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined]
+ self._enforce_land_cap(step_label="Staples (Step 2)")
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '2')
diff --git a/code/deck_builder/phases/phase2_lands_triples.py b/code/deck_builder/phases/phase2_lands_triples.py
index 97fbcd5..8c86bbc 100644
--- a/code/deck_builder/phases/phase2_lands_triples.py
+++ b/code/deck_builder/phases/phase2_lands_triples.py
@@ -59,7 +59,7 @@ class LandTripleMixin:
'forest': 'G',
}
- for _, row in df.iterrows(): # type: ignore
+ for _, row in df.iterrows():
try:
name = str(row.get('name',''))
if not name or name in self.card_library:
diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py
index fe380af..e10b02c 100644
--- a/code/deck_builder/phases/phase3_creatures.py
+++ b/code/deck_builder/phases/phase3_creatures.py
@@ -33,7 +33,7 @@ class CreatureAdditionMixin:
self.output_func("Card pool missing 'type' column; cannot add creatures.")
return
try:
- context = self.get_theme_context() # type: ignore[attr-defined]
+ context = self.get_theme_context()
except Exception:
context = None
if context is None or not getattr(context, 'ordered_targets', []):
@@ -480,7 +480,7 @@ class CreatureAdditionMixin:
drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try:
- import pandas as _pd # type: ignore
+ import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=df.index)
except Exception:
pass
diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py
index 632806d..a0a0f90 100644
--- a/code/deck_builder/phases/phase4_spells.py
+++ b/code/deck_builder/phases/phase4_spells.py
@@ -78,7 +78,7 @@ class SpellAdditionMixin:
# Combine into keep mask
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try:
- import pandas as _pd # type: ignore
+ import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=df.index)
except Exception:
pass
@@ -742,7 +742,7 @@ class SpellAdditionMixin:
if df is None or df.empty or 'type' not in df.columns:
return
try:
- context = self.get_theme_context() # type: ignore[attr-defined]
+ context = self.get_theme_context()
except Exception:
context = None
if context is None or not getattr(context, 'ordered_targets', []):
diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py
index 97e691b..3044736 100644
--- a/code/deck_builder/phases/phase6_reporting.py
+++ b/code/deck_builder/phases/phase6_reporting.py
@@ -14,7 +14,7 @@ from ..shared_copy import build_land_headline, dfc_card_note
logger = logging_util.logging.getLogger(__name__)
try:
- from prettytable import PrettyTable # type: ignore
+ from prettytable import PrettyTable
except Exception: # pragma: no cover
PrettyTable = None # type: ignore
@@ -176,7 +176,7 @@ class ReportingMixin:
"""
try:
# Lazy import to avoid cycles
- from deck_builder.enforcement import enforce_bracket_compliance # type: ignore
+ from deck_builder.enforcement import enforce_bracket_compliance
except Exception:
self.output_func("Enforcement module unavailable.")
return {}
@@ -194,7 +194,7 @@ class ReportingMixin:
if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'):
before = int(total_cards)
try:
- self.fill_remaining_theme_spells() # type: ignore[attr-defined]
+ self.fill_remaining_theme_spells()
except Exception:
pass
# Recompute after filler
@@ -239,13 +239,13 @@ class ReportingMixin:
csv_name = base_stem + ".csv"
txt_name = base_stem + ".txt"
# Overwrite exports with updated library
- self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) # type: ignore[attr-defined]
- self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # type: ignore[attr-defined]
+ self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True)
+ self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True)
# Re-export the JSON config to reflect any changes from enforcement
json_name = base_stem + ".json"
- self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # type: ignore[attr-defined]
+ self.export_run_config_json(directory='config', filename=json_name, suppress_output=True)
# Recompute and write compliance next to them
- self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined]
+ self.compute_and_print_compliance(base_stem=base_stem)
# Inject enforcement details into the saved compliance JSON for UI transparency
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
try:
@@ -259,18 +259,18 @@ class ReportingMixin:
pass
else:
# Fall back to default export flow
- csv_path = self.export_decklist_csv() # type: ignore[attr-defined]
+ csv_path = self.export_decklist_csv()
try:
base, _ = _os.path.splitext(csv_path)
base_only = _os.path.basename(base)
except Exception:
base_only = None
- self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined]
+ self.export_decklist_text(filename=(base_only + '.txt') if base_only else None)
# Re-export JSON config after enforcement changes
if base_only:
- self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) # type: ignore[attr-defined]
+ self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True)
if base_only:
- self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined]
+ self.compute_and_print_compliance(base_stem=base_only)
# Inject enforcement into written JSON as above
try:
comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json")
@@ -294,7 +294,7 @@ class ReportingMixin:
"""
try:
# Late import to avoid circulars in some environments
- from deck_builder.brackets_compliance import evaluate_deck # type: ignore
+ from deck_builder.brackets_compliance import evaluate_deck
except Exception:
self.output_func("Bracket compliance module unavailable.")
return {}
@@ -373,7 +373,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df
- row_lookup: Dict[str, any] = {}
+ row_lookup: Dict[str, Any] = {}
if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows():
nm = str(r.get('name'))
@@ -429,7 +429,7 @@ class ReportingMixin:
# Surface land vs. MDFC counts for CLI users to mirror web summary copy
try:
- summary = self.build_deck_summary() # type: ignore[attr-defined]
+ summary = self.build_deck_summary()
except Exception:
summary = None
if isinstance(summary, dict):
@@ -483,9 +483,9 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df
- row_lookup: Dict[str, any] = {}
+ row_lookup: Dict[str, Any] = {}
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
- for _, r in snapshot.iterrows(): # type: ignore[attr-defined]
+ for _, r in snapshot.iterrows():
nm = str(r.get('name'))
if nm and nm not in row_lookup:
row_lookup[nm] = r
@@ -521,7 +521,7 @@ class ReportingMixin:
builder_utils_module = None
try:
- from deck_builder import builder_utils as _builder_utils # type: ignore
+ from deck_builder import builder_utils as _builder_utils
builder_utils_module = _builder_utils
color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df)
except Exception:
@@ -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),
@@ -852,7 +856,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df
- row_lookup: Dict[str, any] = {}
+ row_lookup: Dict[str, Any] = {}
if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows():
nm = str(r.get('name'))
@@ -1124,7 +1128,7 @@ class ReportingMixin:
full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df
- row_lookup: Dict[str, any] = {}
+ row_lookup: Dict[str, Any] = {}
if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns:
for _, r in snapshot.iterrows():
nm = str(r.get('name'))
@@ -1132,7 +1136,7 @@ class ReportingMixin:
row_lookup[nm] = r
try:
- from deck_builder import builder_utils as _builder_utils # type: ignore
+ from deck_builder import builder_utils as _builder_utils
color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df)
except Exception:
color_matrix = {}
@@ -1383,3 +1387,4 @@ class ReportingMixin:
"""
# Card library printout suppressed; use CSV and text export for card list.
pass
+
diff --git a/code/deck_builder/random_entrypoint.py b/code/deck_builder/random_entrypoint.py
index 6f9526d..8b00d40 100644
--- a/code/deck_builder/random_entrypoint.py
+++ b/code/deck_builder/random_entrypoint.py
@@ -885,7 +885,7 @@ def _filter_multi(df: pd.DataFrame, primary: Optional[str], secondary: Optional[
if index_map is None:
_ensure_theme_tag_index(current_df)
index_map = current_df.attrs.get("_ltag_index") or {}
- return index_map # type: ignore[return-value]
+ return index_map
index_map_all = _get_index_map(df)
@@ -1047,7 +1047,7 @@ def _check_constraints(candidate_count: int, constraints: Optional[Dict[str, Any
if not constraints:
return
try:
- req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined]
+ req_min = constraints.get("require_min_candidates")
except Exception:
req_min = None
if req_min is None:
@@ -1436,7 +1436,7 @@ def build_random_full_deck(
primary_choice_idx, secondary_choice_idx, tertiary_choice_idx = _resolve_theme_choices_for_headless(base.commander, base)
try:
- from headless_runner import run as _run # type: ignore
+ from headless_runner import run as _run
except Exception as e:
return RandomFullBuildResult(
seed=base.seed,
@@ -1482,7 +1482,7 @@ def build_random_full_deck(
summary: Dict[str, Any] | None = None
try:
if hasattr(builder, 'build_deck_summary'):
- summary = builder.build_deck_summary() # type: ignore[attr-defined]
+ summary = builder.build_deck_summary()
except Exception:
summary = None
@@ -1559,7 +1559,7 @@ def build_random_full_deck(
if isinstance(custom_base, str) and custom_base.strip():
meta_payload["name"] = custom_base.strip()
try:
- commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined]
+ commander_meta = builder.get_commander_export_metadata()
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
@@ -1589,8 +1589,8 @@ def build_random_full_deck(
try:
import os as _os
import json as _json
- csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined]
- txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined]
+ csv_path = getattr(builder, 'last_csv_path', None)
+ txt_path = getattr(builder, 'last_txt_path', None)
if csv_path and isinstance(csv_path, str):
base_path, _ = _os.path.splitext(csv_path)
# If txt missing but expected, look for sibling
@@ -1608,7 +1608,7 @@ def build_random_full_deck(
# Compute compliance if not already saved
try:
if hasattr(builder, 'compute_and_print_compliance'):
- compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
+ compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
except Exception:
compliance = None
# Write summary sidecar if missing
@@ -1646,7 +1646,7 @@ def build_random_full_deck(
csv_path = existing_base
base_path, _ = _os.path.splitext(csv_path)
else:
- tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined]
+ tmp_csv = builder.export_decklist_csv()
stem_base, ext = _os.path.splitext(tmp_csv)
if stem_base.endswith('_1'):
original = stem_base[:-2] + ext
@@ -1662,13 +1662,13 @@ def build_random_full_deck(
if _os.path.isfile(target_txt):
txt_path = target_txt
else:
- tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined]
+ tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt')
if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt):
txt_path = target_txt
else:
txt_path = tmp_txt
if hasattr(builder, 'compute_and_print_compliance'):
- compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
+ compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
if summary:
sidecar = base_path + '.summary.json'
if not _os.path.isfile(sidecar):
diff --git a/code/deck_builder/summary_telemetry.py b/code/deck_builder/summary_telemetry.py
index 6afa02c..3bd38a3 100644
--- a/code/deck_builder/summary_telemetry.py
+++ b/code/deck_builder/summary_telemetry.py
@@ -167,7 +167,7 @@ def _reset_metrics_for_test() -> None:
def _sanitize_theme_list(values: Iterable[Any]) -> list[str]:
sanitized: list[str] = []
seen: set[str] = set()
- for raw in values or []: # type: ignore[arg-type]
+ for raw in values or []:
text = str(raw or "").strip()
if not text:
continue
diff --git a/code/deck_builder/theme_catalog_loader.py b/code/deck_builder/theme_catalog_loader.py
index c4d20ac..7d1214b 100644
--- a/code/deck_builder/theme_catalog_loader.py
+++ b/code/deck_builder/theme_catalog_loader.py
@@ -183,7 +183,7 @@ def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]:
try:
from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path
except ImportError: # pragma: no cover - fallback when running as package
- from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
+ from code.type_definitions_theme_catalog import ThemeCatalog
try:
catalog = ThemeCatalog.model_validate(payload)
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/old/setup.py b/code/file_setup/old/setup.py
index b377017..104aa06 100644
--- a/code/file_setup/old/setup.py
+++ b/code/file_setup/old/setup.py
@@ -40,7 +40,7 @@ from typing import List, Dict, Any
# Third-party imports (optional)
try:
- import inquirer # type: ignore
+ import inquirer
except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd
diff --git a/code/file_setup/old/setup_csv.py b/code/file_setup/old/setup_csv.py
index c48dc9d..247597f 100644
--- a/code/file_setup/old/setup_csv.py
+++ b/code/file_setup/old/setup_csv.py
@@ -40,7 +40,7 @@ from typing import List, Dict, Any
# Third-party imports (optional)
try:
- import inquirer # type: ignore
+ import inquirer
except Exception:
inquirer = None # Fallback to simple input-based menu when unavailable
import pandas as pd
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/headless_runner.py b/code/headless_runner.py
index 0292ccd..ff3bfbc 100644
--- a/code/headless_runner.py
+++ b/code/headless_runner.py
@@ -139,7 +139,7 @@ def _validate_commander_available(command_name: str) -> None:
return
try:
- from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found]
+ from commander_exclusions import lookup_commander_detail as _lookup_commander_detail
except ImportError: # pragma: no cover
_lookup_commander_detail = None
@@ -281,12 +281,12 @@ def run(
# Optional deterministic seed for Random Modes (does not affect core when unset)
try:
if seed is not None:
- builder.set_seed(seed) # type: ignore[attr-defined]
+ builder.set_seed(seed)
except Exception:
pass
# Mark this run as headless so builder can adjust exports and logging
try:
- builder.headless = True # type: ignore[attr-defined]
+ builder.headless = True
except Exception:
pass
@@ -294,9 +294,9 @@ def run(
secondary_clean = (secondary_commander or "").strip()
background_clean = (background or "").strip()
try:
- builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined]
- builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined]
- builder.requested_background = background_clean or None # type: ignore[attr-defined]
+ builder.partner_feature_enabled = partner_feature_enabled
+ builder.requested_secondary_commander = secondary_clean or None
+ builder.requested_background = background_clean or None
except Exception:
pass
@@ -313,11 +313,11 @@ def run(
# Configure include/exclude settings (M1: Config + Validation + Persistence)
try:
- builder.include_cards = list(include_cards or []) # type: ignore[attr-defined]
- builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined]
- builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined]
- builder.allow_illegal = allow_illegal # type: ignore[attr-defined]
- builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined]
+ builder.include_cards = list(include_cards or [])
+ builder.exclude_cards = list(exclude_cards or [])
+ builder.enforcement_mode = enforcement_mode
+ builder.allow_illegal = allow_illegal
+ builder.fuzzy_matching = fuzzy_matching
except Exception:
pass
@@ -336,16 +336,16 @@ def run(
)
try:
- builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined]
- builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined]
- builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined]
- builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined]
- builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined]
- builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined]
- builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined]
- builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined]
+ builder.theme_match_mode = theme_resolution.mode
+ builder.theme_catalog_version = theme_resolution.catalog_version
+ builder.user_theme_requested = list(theme_resolution.requested)
+ builder.user_theme_resolved = list(theme_resolution.resolved)
+ builder.user_theme_matches = list(theme_resolution.matches)
+ builder.user_theme_unresolved = list(theme_resolution.unresolved)
+ builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections)
+ builder.user_theme_resolution = theme_resolution
if user_theme_weight is not None:
- builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined]
+ builder.user_theme_weight = float(user_theme_weight)
except Exception:
pass
@@ -356,7 +356,7 @@ def run(
ic: Dict[str, int] = {}
for k, v in ideal_counts.items():
try:
- iv = int(v) if v is not None else None # type: ignore
+ iv = int(v) if v is not None else None
except Exception:
continue
if iv is None:
@@ -365,7 +365,7 @@ def run(
if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}:
ic[k] = iv
if ic:
- builder.ideal_counts.update(ic) # type: ignore[attr-defined]
+ builder.ideal_counts.update(ic)
except Exception:
pass
builder.run_initial_setup()
@@ -518,24 +518,24 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commande
"""Attach combined commander metadata to the builder for downstream use."""
try:
- builder.combined_commander = combined_commander # type: ignore[attr-defined]
+ builder.combined_commander = combined_commander
except Exception:
pass
try:
- builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined]
+ builder.partner_mode = combined_commander.partner_mode
except Exception:
pass
try:
- builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined]
+ builder.secondary_commander = combined_commander.secondary_name
except Exception:
pass
try:
- builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined]
- builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined]
- builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined]
+ builder.combined_color_identity = combined_commander.color_identity
+ builder.combined_theme_tags = combined_commander.theme_tags
+ builder.partner_warnings = combined_commander.warnings
except Exception:
pass
@@ -557,7 +557,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
# Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export
if csv_path:
try:
- builder.last_csv_path = csv_path # type: ignore[attr-defined]
+ builder.last_csv_path = csv_path
except Exception:
pass
except Exception:
@@ -572,7 +572,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
finally:
if txt_generated:
try:
- builder.last_txt_path = txt_generated # type: ignore[attr-defined]
+ builder.last_txt_path = txt_generated
except Exception:
pass
else:
@@ -582,7 +582,7 @@ def _export_outputs(builder: DeckBuilder) -> None:
finally:
if txt_generated:
try:
- builder.last_txt_path = txt_generated # type: ignore[attr-defined]
+ builder.last_txt_path = txt_generated
except Exception:
pass
except Exception:
@@ -1196,7 +1196,7 @@ def _run_random_mode(config: RandomRunConfig) -> int:
RandomConstraintsImpossibleError,
RandomThemeNoMatchError,
build_random_full_deck,
- ) # type: ignore
+ )
except Exception as exc:
print(f"Random mode unavailable: {exc}")
return 1
diff --git a/code/scripts/build_theme_catalog.py b/code/scripts/build_theme_catalog.py
index 43c70ca..4f2f722 100644
--- a/code/scripts/build_theme_catalog.py
+++ b/code/scripts/build_theme_catalog.py
@@ -36,7 +36,7 @@ except Exception: # pragma: no cover
try:
# Support running as `python code/scripts/build_theme_catalog.py` when 'code' already on path
- from scripts.extract_themes import ( # type: ignore
+ from scripts.extract_themes import (
BASE_COLORS,
collect_theme_tags_from_constants,
collect_theme_tags_from_tagger_source,
@@ -51,7 +51,7 @@ try:
)
except ModuleNotFoundError:
# Fallback: direct relative import when running within scripts package context
- from extract_themes import ( # type: ignore
+ from extract_themes import (
BASE_COLORS,
collect_theme_tags_from_constants,
collect_theme_tags_from_tagger_source,
@@ -66,7 +66,7 @@ except ModuleNotFoundError:
)
try:
- from scripts.export_themes_to_yaml import slugify as slugify_theme # type: ignore
+ from scripts.export_themes_to_yaml import slugify as slugify_theme
except Exception:
_SLUG_RE = re.compile(r'[^a-z0-9-]')
@@ -951,7 +951,7 @@ def main(): # pragma: no cover
if args.schema:
# Lazy import to avoid circular dependency: replicate minimal schema inline from models file if present
try:
- from type_definitions_theme_catalog import ThemeCatalog # type: ignore
+ from type_definitions_theme_catalog import ThemeCatalog
import json as _json
print(_json.dumps(ThemeCatalog.model_json_schema(), indent=2))
return
@@ -990,8 +990,8 @@ def main(): # pragma: no cover
# Safeguard: if catalog dir missing, attempt to auto-export Phase A YAML first
if not CATALOG_DIR.exists(): # pragma: no cover (environmental)
try:
- from scripts.export_themes_to_yaml import main as export_main # type: ignore
- export_main(['--force']) # type: ignore[arg-type]
+ from scripts.export_themes_to_yaml import main as export_main
+ export_main(['--force'])
except Exception as _e:
print(f"[build_theme_catalog] WARNING: catalog dir missing and auto export failed: {_e}", file=sys.stderr)
if yaml is None:
@@ -1013,7 +1013,7 @@ def main(): # pragma: no cover
meta_block = raw.get('metadata_info') if isinstance(raw.get('metadata_info'), dict) else {}
# Legacy migration: if no metadata_info but legacy provenance present, adopt it
if not meta_block and isinstance(raw.get('provenance'), dict):
- meta_block = raw.get('provenance') # type: ignore
+ meta_block = raw.get('provenance')
changed = True
if force or not meta_block.get('last_backfill'):
meta_block['last_backfill'] = time.strftime('%Y-%m-%dT%H:%M:%S')
diff --git a/code/scripts/export_themes_to_yaml.py b/code/scripts/export_themes_to_yaml.py
index a417e53..6f1d904 100644
--- a/code/scripts/export_themes_to_yaml.py
+++ b/code/scripts/export_themes_to_yaml.py
@@ -41,7 +41,7 @@ SCRIPT_ROOT = Path(__file__).resolve().parent
CODE_ROOT = SCRIPT_ROOT.parent
if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT))
-from scripts.extract_themes import derive_synergies_for_tags # type: ignore
+from scripts.extract_themes import derive_synergies_for_tags
ROOT = Path(__file__).resolve().parents[2]
THEME_JSON = ROOT / 'config' / 'themes' / 'theme_list.json'
diff --git a/code/scripts/extract_themes.py b/code/scripts/extract_themes.py
index c45e7c5..c4c1216 100644
--- a/code/scripts/extract_themes.py
+++ b/code/scripts/extract_themes.py
@@ -18,8 +18,8 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
-from code.settings import CSV_DIRECTORY # type: ignore
-from code.tagging import tag_constants # type: ignore
+from code.settings import CSV_DIRECTORY
+from code.tagging import tag_constants
BASE_COLORS = {
'white': 'W',
diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py
index 39f197b..0ee68d4 100644
--- a/code/scripts/generate_theme_catalog.py
+++ b/code/scripts/generate_theme_catalog.py
@@ -32,7 +32,7 @@ if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT))
try:
- from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore
+ from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY
except Exception: # pragma: no cover - fallback for adhoc execution
DEFAULT_CSV_DIRECTORY = "csv_files"
diff --git a/code/scripts/profile_multi_theme_filter.py b/code/scripts/profile_multi_theme_filter.py
index 2af36c0..795bc62 100644
--- a/code/scripts/profile_multi_theme_filter.py
+++ b/code/scripts/profile_multi_theme_filter.py
@@ -42,7 +42,7 @@ def _sample_combinations(tags: List[str], iterations: int) -> List[Tuple[str | N
def _collect_tag_pool(df: pd.DataFrame) -> List[str]:
tag_pool: set[str] = set()
- for tags in df.get("_ltags", []): # type: ignore[assignment]
+ for tags in df.get("_ltags", []):
if not tags:
continue
for token in tags:
diff --git a/code/scripts/refresh_commander_catalog.py b/code/scripts/refresh_commander_catalog.py
index c9f107e..19b4634 100644
--- a/code/scripts/refresh_commander_catalog.py
+++ b/code/scripts/refresh_commander_catalog.py
@@ -37,7 +37,7 @@ def _refresh_setup() -> None:
def _refresh_tags() -> None:
tagger = importlib.import_module("code.tagging.tagger")
- tagger = importlib.reload(tagger) # type: ignore[assignment]
+ tagger = importlib.reload(tagger)
for color in SUPPORTED_COLORS:
tagger.load_dataframe(color)
diff --git a/code/scripts/report_random_theme_pool.py b/code/scripts/report_random_theme_pool.py
index 1b3833f..09140ae 100644
--- a/code/scripts/report_random_theme_pool.py
+++ b/code/scripts/report_random_theme_pool.py
@@ -21,7 +21,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT))
-from deck_builder.random_entrypoint import ( # type: ignore # noqa: E402
+from deck_builder.random_entrypoint import ( # noqa: E402
_build_random_theme_pool,
_ensure_theme_tag_cache,
_load_commanders_df,
diff --git a/code/scripts/synergy_promote_fill.py b/code/scripts/synergy_promote_fill.py
index 3c49af0..ca878f2 100644
--- a/code/scripts/synergy_promote_fill.py
+++ b/code/scripts/synergy_promote_fill.py
@@ -731,7 +731,7 @@ def main(): # pragma: no cover (script orchestration)
if cand:
theme_card_hits[display] = cand
# Build global duplicate frequency map ONCE (baseline prior to this run) if threshold active
- if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): # type: ignore
+ if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals():
freq: Dict[str, int] = {}
total_themes = 0
for fp0 in CATALOG_DIR.glob('*.yml'):
@@ -748,10 +748,10 @@ def main(): # pragma: no cover (script orchestration)
continue
seen_local.add(c)
freq[c] = freq.get(c, 0) + 1
- globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # type: ignore
+ globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes)
# Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards)
- if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore
- freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore
+ if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals():
+ freq_map, total_prev = globals()['GLOBAL_CARD_FREQ']
if total_prev > 0: # avoid div-by-zero
cutoff = args.common_card_threshold
def _filter(lst: List[Tuple[float, str, Set[str]]]) -> List[Tuple[float, str, Set[str]]]:
@@ -803,8 +803,8 @@ def main(): # pragma: no cover (script orchestration)
print(f"[promote] modified {changed_count} themes")
if args.fill_example_cards:
print(f"[cards] modified {cards_changed} themes (target {args.cards_target})")
- if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore
- freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore
+ if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals():
+ freq_map, total_prev = globals()['GLOBAL_CARD_FREQ']
if total_prev:
items = sorted(freq_map.items(), key=lambda x: (-x[1], x[0]))[:30]
print('[dup-metrics] Top shared example_cards (baseline before this run):')
diff --git a/code/scripts/validate_theme_catalog.py b/code/scripts/validate_theme_catalog.py
index 1b18962..c6b3627 100644
--- a/code/scripts/validate_theme_catalog.py
+++ b/code/scripts/validate_theme_catalog.py
@@ -31,9 +31,9 @@ CODE_ROOT = ROOT / 'code'
if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT))
-from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile # type: ignore
-from scripts.extract_themes import load_whitelist_config # type: ignore
-from scripts.build_theme_catalog import build_catalog # type: ignore
+from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile
+from scripts.extract_themes import load_whitelist_config
+from scripts.build_theme_catalog import build_catalog
CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json'
diff --git a/code/settings.py b/code/settings.py
index 242e58a..fb1caa9 100644
--- a/code/settings.py
+++ b/code/settings.py
@@ -89,11 +89,8 @@ COLUMN_ORDER = CARD_COLUMN_ORDER
TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER
REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS
-MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit']
+# MAIN_MENU_ITEMS, SETUP_MENU_ITEMS, CSV_DIRECTORY already defined above (lines 67-70)
-SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu']
-
-CSV_DIRECTORY: str = 'csv_files'
CARD_FILES_DIRECTORY: str = 'card_files' # Parquet files for consolidated card data
# ----------------------------------------------------------------------------------
@@ -111,11 +108,7 @@ CARD_FILES_PROCESSED_DIR = os.getenv('CARD_FILES_PROCESSED_DIR', os.path.join(CA
# Set to '1' or 'true' to enable CSV fallback when Parquet loading fails
LEGACY_CSV_COMPAT = os.getenv('LEGACY_CSV_COMPAT', '0').lower() in ('1', 'true', 'on', 'enabled')
-# Configuration for handling null/NA values in DataFrame columns
-FILL_NA_COLUMNS: Dict[str, Optional[str]] = {
- 'colorIdentity': 'Colorless', # Default color identity for cards without one
- 'faceName': None # Use card's name column value when face name is not available
-}
+# FILL_NA_COLUMNS already defined above (lines 75-78)
# ----------------------------------------------------------------------------------
# ALL CARDS CONSOLIDATION FEATURE FLAG
diff --git a/code/tagging/bracket_policy_applier.py b/code/tagging/bracket_policy_applier.py
index 80c63b0..5265dd7 100644
--- a/code/tagging/bracket_policy_applier.py
+++ b/code/tagging/bracket_policy_applier.py
@@ -30,14 +30,14 @@ try:
import logging_util
except Exception:
# Fallback for direct module loading
- import importlib.util # type: ignore
+ import importlib.util
root = Path(__file__).resolve().parents[1]
lu_path = root / 'logging_util.py'
spec = importlib.util.spec_from_file_location('logging_util', str(lu_path))
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader
- spec.loader.exec_module(mod) # type: ignore[assignment]
- logging_util = mod # type: ignore
+ spec.loader.exec_module(mod)
+ logging_util = mod
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
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/tagging/old/tagger.py b/code/tagging/old/tagger.py
index b805102..db31b43 100644
--- a/code/tagging/old/tagger.py
+++ b/code/tagging/old/tagger.py
@@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str):
def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
- try: # type: ignore[name-defined]
+ try:
_DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True)
path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv"
df.to_csv(path, index=False)
diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py
index cc08214..3251bf6 100644
--- a/code/tagging/tagger.py
+++ b/code/tagging/tagger.py
@@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str):
def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None:
"""Write DFC compatibility snapshot (diagnostic output, kept as CSV for now)."""
- try: # type: ignore[name-defined]
+ try:
_DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True)
path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv"
df.to_csv(path, index=False) # M3: Kept as CSV (diagnostic only, not main data flow)
diff --git a/code/tests/test_bracket_policy_applier.py b/code/tests/test_bracket_policy_applier.py
index d7d5dfe..17ad9c8 100644
--- a/code/tests/test_bracket_policy_applier.py
+++ b/code/tests/test_bracket_policy_applier.py
@@ -11,9 +11,9 @@ def _load_applier():
root = Path(__file__).resolve().parents[2]
mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py'
spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path))
- mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
+ mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
- spec.loader.exec_module(mod) # type: ignore[assignment]
+ spec.loader.exec_module(mod)
return mod
diff --git a/code/tests/test_card_index_color_identity_edge_cases.py b/code/tests/test_card_index_color_identity_edge_cases.py
index 8a734ed..0969bf3 100644
--- a/code/tests/test_card_index_color_identity_edge_cases.py
+++ b/code/tests/test_card_index_color_identity_edge_cases.py
@@ -30,8 +30,8 @@ def test_card_index_color_identity_list_handles_edge_cases(tmp_path, monkeypatch
csv_path = write_csv(tmp_path)
monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path))
# Force rebuild
- card_index._CARD_INDEX.clear() # type: ignore
- card_index._CARD_INDEX_MTIME = None # type: ignore
+ card_index._CARD_INDEX.clear()
+ card_index._CARD_INDEX_MTIME = None
card_index.maybe_build_index()
pool = card_index.get_tag_pool("Blink")
diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py
index d61387a..337edf7 100644
--- a/code/tests/test_commander_build_cta.py
+++ b/code/tests/test_commander_build_cta.py
@@ -8,7 +8,7 @@ from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
diff --git a/code/tests/test_commander_telemetry.py b/code/tests/test_commander_telemetry.py
index d566da4..d978252 100644
--- a/code/tests/test_commander_telemetry.py
+++ b/code/tests/test_commander_telemetry.py
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
from code.web.services import telemetry
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
diff --git a/code/tests/test_commanders_route.py b/code/tests/test_commanders_route.py
index 6f4d064..bf724f7 100644
--- a/code/tests/test_commanders_route.py
+++ b/code/tests/test_commanders_route.py
@@ -7,7 +7,7 @@ from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
from code.web.routes import commanders
from code.web.services import commander_catalog_loader
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog
diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py
index 4d38a2b..7ac06c5 100644
--- a/code/tests/test_diagnostics.py
+++ b/code/tests/test_diagnostics.py
@@ -24,7 +24,7 @@ def load_app_with_env(**env: str) -> types.ModuleType:
os.environ.pop(key, None)
for k, v in env.items():
os.environ[k] = v
- import code.web.app as app_module # type: ignore
+ import code.web.app as app_module
importlib.reload(app_module)
return app_module
diff --git a/code/tests/test_editorial_governance_phase_d_closeout.py b/code/tests/test_editorial_governance_phase_d_closeout.py
index e3713e0..83b1494 100644
--- a/code/tests/test_editorial_governance_phase_d_closeout.py
+++ b/code/tests/test_editorial_governance_phase_d_closeout.py
@@ -50,7 +50,7 @@ def _load_catalog() -> Dict[str, Any]:
def test_deterministic_build_under_seed():
# Import build after setting seed env
os.environ['EDITORIAL_SEED'] = '999'
- from scripts.build_theme_catalog import build_catalog # type: ignore
+ from scripts.build_theme_catalog import build_catalog
first = build_catalog(limit=0, verbose=False)
second = build_catalog(limit=0, verbose=False)
# Drop volatile metadata_info/timestamp fields before comparison
@@ -106,7 +106,7 @@ def test_metadata_info_block_coverage():
def test_synergy_commanders_exclusion_of_examples():
- import yaml # type: ignore
+ import yaml
pattern = re.compile(r" - Synergy \(.*\)$")
violations: List[str] = []
for p in CATALOG_DIR.glob('*.yml'):
@@ -128,7 +128,7 @@ def test_synergy_commanders_exclusion_of_examples():
def test_mapping_trigger_specialization_guard():
- import yaml # type: ignore
+ import yaml
assert MAPPING.exists(), "description_mapping.yml missing"
mapping_yaml = yaml.safe_load(MAPPING.read_text(encoding='utf-8')) or []
triggers: Set[str] = set()
diff --git a/code/tests/test_home_actions_buttons.py b/code/tests/test_home_actions_buttons.py
index 0dd2815..d9aaec3 100644
--- a/code/tests/test_home_actions_buttons.py
+++ b/code/tests/test_home_actions_buttons.py
@@ -20,7 +20,7 @@ def load_app_with_env(**env: str) -> types.ModuleType:
os.environ.pop(key, None)
for k, v in env.items():
os.environ[k] = v
- import code.web.app as app_module # type: ignore
+ import code.web.app as app_module
importlib.reload(app_module)
return app_module
diff --git a/code/tests/test_land_summary_totals.py b/code/tests/test_land_summary_totals.py
index 9fddcb2..b08ed16 100644
--- a/code/tests/test_land_summary_totals.py
+++ b/code/tests/test_land_summary_totals.py
@@ -14,7 +14,7 @@ class DummyBuilder(ReportingMixin):
self.card_library = card_library
self.color_identity = colors
self.output_lines: List[str] = []
- self.output_func = self.output_lines.append # type: ignore[assignment]
+ self.output_func = self.output_lines.append
self._full_cards_df = None
self._combined_cards_df = None
self.include_exclude_diagnostics = None
diff --git a/code/tests/test_mdfc_basic_swap.py b/code/tests/test_mdfc_basic_swap.py
index e78dafa..535f8da 100644
--- a/code/tests/test_mdfc_basic_swap.py
+++ b/code/tests/test_mdfc_basic_swap.py
@@ -20,7 +20,7 @@ def _stub_modal_matrix(builder: DeckBuilder) -> None:
"Forest": {"G": 1},
}
- builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined]
+ builder._compute_color_source_matrix = MethodType(fake_matrix, builder)
def test_modal_dfc_swaps_basic_when_enabled():
diff --git a/code/tests/test_multicopy_clamp_strong.py b/code/tests/test_multicopy_clamp_strong.py
index b7cdc4d..3538e6c 100644
--- a/code/tests/test_multicopy_clamp_strong.py
+++ b/code/tests/test_multicopy_clamp_strong.py
@@ -18,7 +18,7 @@ def test_multicopy_clamp_trims_current_stage_additions_only():
# Preseed 95 cards in the library
b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}}
# Set a multi-copy selection that would exceed 100 by 15
- b._web_multi_copy = { # type: ignore[attr-defined]
+ b._web_multi_copy = {
"id": "persistent_petitioners",
"name": "Persistent Petitioners",
"count": 20,
diff --git a/code/tests/test_multicopy_petitioners_clamp.py b/code/tests/test_multicopy_petitioners_clamp.py
index e7a37c7..dfa8b7f 100644
--- a/code/tests/test_multicopy_petitioners_clamp.py
+++ b/code/tests/test_multicopy_petitioners_clamp.py
@@ -23,7 +23,7 @@ def test_petitioners_clamp_to_100_and_reduce_creature_slots():
"card_advantage": 8, "protection": 4,
}
# Thread multi-copy selection for Petitioners as a creature archetype
- b._web_multi_copy = { # type: ignore[attr-defined]
+ b._web_multi_copy = {
"id": "persistent_petitioners",
"name": "Persistent Petitioners",
"count": 40, # intentionally large to trigger clamp/adjustments
diff --git a/code/tests/test_multicopy_stage_runner.py b/code/tests/test_multicopy_stage_runner.py
index 886b277..4054fc0 100644
--- a/code/tests/test_multicopy_stage_runner.py
+++ b/code/tests/test_multicopy_stage_runner.py
@@ -17,7 +17,7 @@ def _minimal_ctx(selection: dict):
b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True)
# Thread selection and ensure empty library
- b._web_multi_copy = selection # type: ignore[attr-defined]
+ b._web_multi_copy = selection
b.card_library = {}
ctx = {
diff --git a/code/tests/test_multicopy_web_flow.py b/code/tests/test_multicopy_web_flow.py
index 22fb79a..52f64c2 100644
--- a/code/tests/test_multicopy_web_flow.py
+++ b/code/tests/test_multicopy_web_flow.py
@@ -1,7 +1,7 @@
import importlib
import pytest
try:
- from starlette.testclient import TestClient # type: ignore
+ from starlette.testclient import TestClient
except Exception: # pragma: no cover - optional dep in CI
TestClient = None # type: ignore
diff --git a/code/tests/test_partner_suggestions_api.py b/code/tests/test_partner_suggestions_api.py
index a54838f..5180329 100644
--- a/code/tests/test_partner_suggestions_api.py
+++ b/code/tests/test_partner_suggestions_api.py
@@ -128,7 +128,7 @@ def _make_request(path: str = "/api/partner/suggestions", query_string: str = ""
"client": ("203.0.113.5", 52345),
"server": ("testserver", 80),
}
- request = Request(scope, receive=_receive) # type: ignore[arg-type]
+ request = Request(scope, receive=_receive)
request.state.request_id = "req-telemetry"
return request
@@ -197,21 +197,21 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey
from code.web.services import orchestrator as orchestrator_service
original_default = partner_service.DEFAULT_DATASET_PATH
- original_path = partner_service._DATASET_PATH # type: ignore[attr-defined]
- original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined]
- original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined]
+ original_path = partner_service._DATASET_PATH
+ original_cache = partner_service._DATASET_CACHE
+ original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED
partner_service.DEFAULT_DATASET_PATH = dataset_path
- partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined]
- partner_service._DATASET_CACHE = None # type: ignore[attr-defined]
- partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined]
+ partner_service._DATASET_PATH = dataset_path
+ partner_service._DATASET_CACHE = None
+ partner_service._DATASET_REFRESH_ATTEMPTED = True
calls = {"count": 0}
payload_path = tmp_path / "seed_dataset.json"
_write_dataset(payload_path)
- def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override]
+ def seeded_refresh(out_func=None, *, force=False, root=None):
calls["count"] += 1
dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
@@ -227,9 +227,9 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey
assert calls["count"] == 1
finally:
partner_service.DEFAULT_DATASET_PATH = original_default
- partner_service._DATASET_PATH = original_path # type: ignore[attr-defined]
- partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined]
- partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined]
+ partner_service._DATASET_PATH = original_path
+ partner_service._DATASET_CACHE = original_cache
+ partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted
try:
dataset_path.unlink()
except FileNotFoundError:
diff --git a/code/tests/test_partner_synergy_refresh.py b/code/tests/test_partner_synergy_refresh.py
index cf3c2e1..984b79a 100644
--- a/code/tests/test_partner_synergy_refresh.py
+++ b/code/tests/test_partner_synergy_refresh.py
@@ -33,7 +33,7 @@ def _invoke_helper(
) -> list[tuple[list[str], str]]:
calls: list[tuple[list[str], str]] = []
- def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def]
+ def _fake_run(cmd, check=False, cwd=None):
calls.append((list(cmd), cwd))
class _Completed:
returncode = 0
diff --git a/code/tests/test_preview_cache_redis_poc.py b/code/tests/test_preview_cache_redis_poc.py
index 34e8c1e..afe616e 100644
--- a/code/tests/test_preview_cache_redis_poc.py
+++ b/code/tests/test_preview_cache_redis_poc.py
@@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi")
def load_app_with_env(**env: str) -> types.ModuleType:
for k,v in env.items():
os.environ[k] = v
- import code.web.app as app_module # type: ignore
+ import code.web.app as app_module
importlib.reload(app_module)
return app_module
diff --git a/code/tests/test_preview_curated_examples_regression.py b/code/tests/test_preview_curated_examples_regression.py
index 9839784..fc81d13 100644
--- a/code/tests/test_preview_curated_examples_regression.py
+++ b/code/tests/test_preview_curated_examples_regression.py
@@ -1,7 +1,7 @@
import json
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
def test_preview_includes_curated_examples_regression():
diff --git a/code/tests/test_preview_eviction_advanced.py b/code/tests/test_preview_eviction_advanced.py
index 63447d5..337b6c2 100644
--- a/code/tests/test_preview_eviction_advanced.py
+++ b/code/tests/test_preview_eviction_advanced.py
@@ -1,8 +1,8 @@
import os
-from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore
-from code.web.services import preview_cache as pc # type: ignore
-from code.web.services.preview_metrics import preview_metrics # type: ignore
+from code.web.services.theme_preview import get_theme_preview, bust_preview_cache
+from code.web.services import preview_cache as pc
+from code.web.services.preview_metrics import preview_metrics
def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None):
@@ -89,7 +89,7 @@ def test_env_weight_override(monkeypatch):
bust_preview_cache()
# Clear module-level caches for weights
if hasattr(pc, '_EVICT_WEIGHTS_CACHE'):
- pc._EVICT_WEIGHTS_CACHE = None # type: ignore
+ pc._EVICT_WEIGHTS_CACHE = None
# Create two entries: one older with many hits, one fresh with none.
_prime('Blink', limit=6, hits=6, colors=None) # older hot entry
old_key = next(iter(pc.PREVIEW_CACHE.keys()))
diff --git a/code/tests/test_preview_eviction_basic.py b/code/tests/test_preview_eviction_basic.py
index 848bcce..804c2d5 100644
--- a/code/tests/test_preview_eviction_basic.py
+++ b/code/tests/test_preview_eviction_basic.py
@@ -1,6 +1,6 @@
import os
-from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore
-from code.web.services import preview_cache as pc # type: ignore
+from code.web.services.theme_preview import get_theme_preview, bust_preview_cache
+from code.web.services import preview_cache as pc
def test_basic_low_score_eviction(monkeypatch):
@@ -17,7 +17,7 @@ def test_basic_low_score_eviction(monkeypatch):
get_theme_preview('Blink', limit=6, colors=c)
# Cache limit 5, inserted 6 distinct -> eviction should have occurred
assert len(pc.PREVIEW_CACHE) <= 5
- from code.web.services.preview_metrics import preview_metrics # type: ignore
+ from code.web.services.preview_metrics import preview_metrics
m = preview_metrics()
assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction'
assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1
diff --git a/code/tests/test_preview_minimal_variant.py b/code/tests/test_preview_minimal_variant.py
index 2fec530..b134a23 100644
--- a/code/tests/test_preview_minimal_variant.py
+++ b/code/tests/test_preview_minimal_variant.py
@@ -1,5 +1,5 @@
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
def test_minimal_variant_hides_controls_and_headers():
diff --git a/code/tests/test_preview_perf_fetch_retry.py b/code/tests/test_preview_perf_fetch_retry.py
index 50b7ee5..a0bdb9a 100644
--- a/code/tests/test_preview_perf_fetch_retry.py
+++ b/code/tests/test_preview_perf_fetch_retry.py
@@ -8,7 +8,7 @@ pytestmark = pytest.mark.skip(reason="M4: preview_perf_benchmark module removed
def test_fetch_all_theme_slugs_retries(monkeypatch):
calls = {"count": 0}
- def fake_fetch(url): # type: ignore[override]
+ def fake_fetch(url):
calls["count"] += 1
if calls["count"] == 1:
raise RuntimeError("transient 500")
@@ -27,7 +27,7 @@ def test_fetch_all_theme_slugs_retries(monkeypatch):
def test_fetch_all_theme_slugs_page_level_retry(monkeypatch):
calls = {"count": 0}
- def fake_fetch_with_retry(url, attempts=3, delay=0.6): # type: ignore[override]
+ def fake_fetch_with_retry(url, attempts=3, delay=0.6):
calls["count"] += 1
if calls["count"] < 3:
raise RuntimeError("service warming up")
diff --git a/code/tests/test_preview_suppress_curated_flag.py b/code/tests/test_preview_suppress_curated_flag.py
index 9ab5283..bea1467 100644
--- a/code/tests/test_preview_suppress_curated_flag.py
+++ b/code/tests/test_preview_suppress_curated_flag.py
@@ -1,5 +1,5 @@
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
def test_preview_fragment_suppress_curated_removes_examples():
diff --git a/code/tests/test_preview_ttl_adaptive.py b/code/tests/test_preview_ttl_adaptive.py
index e4b72b7..aa952d3 100644
--- a/code/tests/test_preview_ttl_adaptive.py
+++ b/code/tests/test_preview_ttl_adaptive.py
@@ -3,16 +3,16 @@ from code.web.services import preview_cache as pc
def _force_interval_elapsed():
# Ensure adaptation interval guard passes
- if pc._LAST_ADAPT_AT is not None: # type: ignore[attr-defined]
- pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) # type: ignore[attr-defined]
+ if pc._LAST_ADAPT_AT is not None:
+ pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1)
def test_ttl_adapts_down_and_up(capsys):
# Enable adaptation regardless of env
- pc._ADAPTATION_ENABLED = True # type: ignore[attr-defined]
- pc.TTL_SECONDS = pc._TTL_BASE # type: ignore[attr-defined]
- pc._RECENT_HITS.clear() # type: ignore[attr-defined]
- pc._LAST_ADAPT_AT = None # type: ignore[attr-defined]
+ pc._ADAPTATION_ENABLED = True
+ pc.TTL_SECONDS = pc._TTL_BASE
+ pc._RECENT_HITS.clear()
+ pc._LAST_ADAPT_AT = None
# Low hit ratio pattern (~0.1)
for _ in range(72):
@@ -23,11 +23,11 @@ def test_ttl_adapts_down_and_up(capsys):
out1 = capsys.readouterr().out
assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio"
ttl_after_down = pc.TTL_SECONDS
- assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined]
+ assert ttl_after_down <= pc._TTL_BASE
# Force interval elapsed & high hit ratio pattern (~0.9)
_force_interval_elapsed()
- pc._RECENT_HITS.clear() # type: ignore[attr-defined]
+ pc._RECENT_HITS.clear()
for _ in range(72):
pc.record_request_hit(True)
for _ in range(8):
diff --git a/code/tests/test_random_rate_limit_headers.py b/code/tests/test_random_rate_limit_headers.py
index 6a18061..6fb2e30 100644
--- a/code/tests/test_random_rate_limit_headers.py
+++ b/code/tests/test_random_rate_limit_headers.py
@@ -19,17 +19,17 @@ def _client_with_flags(window_s: int = 2, limit_random: int = 2, limit_build: in
# Force fresh import so RATE_LIMIT_* constants reflect env
sys.modules.pop('code.web.app', None)
- from code.web import app as app_module # type: ignore
+ from code.web import app as app_module
# Force override constants for deterministic test
try:
- app_module.RATE_LIMIT_ENABLED = True # type: ignore[attr-defined]
- app_module.RATE_LIMIT_WINDOW_S = window_s # type: ignore[attr-defined]
- app_module.RATE_LIMIT_RANDOM = limit_random # type: ignore[attr-defined]
- app_module.RATE_LIMIT_BUILD = limit_build # type: ignore[attr-defined]
- app_module.RATE_LIMIT_SUGGEST = limit_suggest # type: ignore[attr-defined]
+ app_module.RATE_LIMIT_ENABLED = True
+ app_module.RATE_LIMIT_WINDOW_S = window_s
+ app_module.RATE_LIMIT_RANDOM = limit_random
+ app_module.RATE_LIMIT_BUILD = limit_build
+ app_module.RATE_LIMIT_SUGGEST = limit_suggest
# Reset in-memory counters
if hasattr(app_module, '_RL_COUNTS'):
- app_module._RL_COUNTS.clear() # type: ignore[attr-defined]
+ app_module._RL_COUNTS.clear()
except Exception:
pass
return TestClient(app_module.app)
diff --git a/code/tests/test_random_theme_stats_diagnostics.py b/code/tests/test_random_theme_stats_diagnostics.py
index 5602ba4..5c71326 100644
--- a/code/tests/test_random_theme_stats_diagnostics.py
+++ b/code/tests/test_random_theme_stats_diagnostics.py
@@ -3,8 +3,8 @@ from pathlib import Path
from fastapi.testclient import TestClient
-from code.web import app as web_app # type: ignore
-from code.web.app import app # type: ignore
+from code.web import app as web_app
+from code.web.app import app
# Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2]
diff --git a/code/tests/test_sampling_unit.py b/code/tests/test_sampling_unit.py
index 2f09806..711c856 100644
--- a/code/tests/test_sampling_unit.py
+++ b/code/tests/test_sampling_unit.py
@@ -9,17 +9,17 @@ def setup_module(module): # ensure deterministic env weights
def test_rarity_diminishing():
# Monkeypatch internal index
- card_index._CARD_INDEX.clear() # type: ignore
+ card_index._CARD_INDEX.clear()
theme = "Test Theme"
- card_index._CARD_INDEX[theme] = [ # type: ignore
+ card_index._CARD_INDEX[theme] = [
{"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
{"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
]
def no_build():
return None
- sampling.maybe_build_index = no_build # type: ignore
+ sampling.maybe_build_index = no_build
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None)
- rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore
+ rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")]
assert len(rarity_weights) >= 2
v1 = float(rarity_weights[0].split(":")[-1])
v2 = float(rarity_weights[1].split(":")[-1])
@@ -40,15 +40,15 @@ def test_commander_overlap_monotonic_diminishing():
def test_splash_off_color_penalty_applied():
- card_index._CARD_INDEX.clear() # type: ignore
+ card_index._CARD_INDEX.clear()
theme = "Splash Theme"
# Commander W U B R (4 colors)
commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"}
# Card with single off-color G (W U B R G)
splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"}
- card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore
- sampling.maybe_build_index = lambda: None # type: ignore
+ card_index._CARD_INDEX[theme] = [commander, splash_card]
+ sampling.maybe_build_index = lambda: None
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest")
splash = next((c for c in cards if c["name"] == "CardSplash"), None)
assert splash is not None
- assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore
+ assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"])
diff --git a/code/tests/test_scryfall_name_normalization.py b/code/tests/test_scryfall_name_normalization.py
index cdd7c09..f4a6834 100644
--- a/code/tests/test_scryfall_name_normalization.py
+++ b/code/tests/test_scryfall_name_normalization.py
@@ -1,5 +1,5 @@
import re
-from code.web.services.theme_preview import get_theme_preview # type: ignore
+from code.web.services.theme_preview import get_theme_preview
# We can't easily execute the JS normalizeCardName in Python, but we can ensure
# server-delivered sample names that include appended synergy annotations are not
diff --git a/code/tests/test_service_worker_offline.py b/code/tests/test_service_worker_offline.py
index 291e3ca..080a6bb 100644
--- a/code/tests/test_service_worker_offline.py
+++ b/code/tests/test_service_worker_offline.py
@@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") # skip if FastAPI missing
def load_app_with_env(**env: str) -> types.ModuleType:
for k, v in env.items():
os.environ[k] = v
- import code.web.app as app_module # type: ignore
+ import code.web.app as app_module
importlib.reload(app_module)
return app_module
diff --git a/code/tests/test_theme_api_phase_e.py b/code/tests/test_theme_api_phase_e.py
index 0afa5d8..e61252c 100644
--- a/code/tests/test_theme_api_phase_e.py
+++ b/code/tests/test_theme_api_phase_e.py
@@ -2,7 +2,7 @@ import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
-from code.web.app import app # type: ignore
+from code.web.app import app
# Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2]
diff --git a/code/tests/test_theme_catalog_generation.py b/code/tests/test_theme_catalog_generation.py
index 81f6634..9badfc2 100644
--- a/code/tests/test_theme_catalog_generation.py
+++ b/code/tests/test_theme_catalog_generation.py
@@ -146,7 +146,7 @@ def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> No
assert all(row['last_generated_at'] == result.generated_at for row in rows)
assert all(row['version'] == result.version for row in rows)
- expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined]
+ expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows])
assert result.version == expected_hash
diff --git a/code/tests/test_theme_catalog_mapping_and_samples.py b/code/tests/test_theme_catalog_mapping_and_samples.py
index bc661cf..9cdd9c8 100644
--- a/code/tests/test_theme_catalog_mapping_and_samples.py
+++ b/code/tests/test_theme_catalog_mapping_and_samples.py
@@ -4,7 +4,7 @@ import os
import importlib
from pathlib import Path
from starlette.testclient import TestClient
-from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
+from code.type_definitions_theme_catalog import ThemeCatalog
CATALOG_PATH = Path('config/themes/theme_list.json')
diff --git a/code/tests/test_theme_catalog_schema_validation.py b/code/tests/test_theme_catalog_schema_validation.py
index eb8593b..3bff64c 100644
--- a/code/tests/test_theme_catalog_schema_validation.py
+++ b/code/tests/test_theme_catalog_schema_validation.py
@@ -8,7 +8,7 @@ def test_theme_list_json_validates_against_pydantic_and_fast_path():
raw = json.loads(p.read_text(encoding='utf-8'))
# Pydantic validation
- from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
+ from code.type_definitions_theme_catalog import ThemeCatalog
catalog = ThemeCatalog(**raw)
assert isinstance(catalog.themes, list) and len(catalog.themes) > 0
# Basic fields exist on entries
diff --git a/code/tests/test_theme_picker_gaps.py b/code/tests/test_theme_picker_gaps.py
index 6e7f5c9..0146cce 100644
--- a/code/tests/test_theme_picker_gaps.py
+++ b/code/tests/test_theme_picker_gaps.py
@@ -36,7 +36,7 @@ from fastapi.testclient import TestClient
def _get_app(): # local import to avoid heavy import cost if file unused
- from code.web.app import app # type: ignore
+ from code.web.app import app
return app
@@ -115,13 +115,13 @@ def test_preview_cache_hit_timing(monkeypatch, client):
r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r1.status_code == 200
# Monkeypatch theme_preview._now to freeze time so second call counts as hit
- import code.web.services.theme_preview as tp # type: ignore
+ import code.web.services.theme_preview as tp
orig_now = tp._now
monkeypatch.setattr(tp, "_now", lambda: orig_now())
r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r2.status_code == 200
# Deterministic service-level verification: second direct function call should short-circuit via cache
- import code.web.services.theme_preview as tp # type: ignore
+ import code.web.services.theme_preview as tp
# Snapshot counters
pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
first_payload = tp.get_theme_preview(theme_id, limit=12)
diff --git a/code/tests/test_theme_preview_additional.py b/code/tests/test_theme_preview_additional.py
index f9a848f..33aff75 100644
--- a/code/tests/test_theme_preview_additional.py
+++ b/code/tests/test_theme_preview_additional.py
@@ -16,7 +16,7 @@ def _new_client(prewarm: bool = False) -> TestClient:
# Remove existing module (if any) so lifespan runs again
if 'code.web.app' in list(importlib.sys.modules.keys()):
importlib.sys.modules.pop('code.web.app')
- from code.web.app import app # type: ignore
+ from code.web.app import app
return TestClient(app)
diff --git a/code/tests/test_theme_preview_ordering.py b/code/tests/test_theme_preview_ordering.py
index 5cbebdf..f0143f5 100644
--- a/code/tests/test_theme_preview_ordering.py
+++ b/code/tests/test_theme_preview_ordering.py
@@ -2,8 +2,8 @@ from __future__ import annotations
import pytest
-from code.web.services.theme_preview import get_theme_preview # type: ignore
-from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore
+from code.web.services.theme_preview import get_theme_preview
+from code.web.services.theme_catalog_loader import load_index, slugify, project_detail
@pytest.mark.parametrize("limit", [8, 12])
diff --git a/code/tests/test_theme_preview_p0_new.py b/code/tests/test_theme_preview_p0_new.py
index 171893d..a35956f 100644
--- a/code/tests/test_theme_preview_p0_new.py
+++ b/code/tests/test_theme_preview_p0_new.py
@@ -1,7 +1,7 @@
import os
import time
import json
-from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore
+from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache
def test_colors_filter_constraint_green_subset():
diff --git a/code/tests/test_theme_spell_weighting.py b/code/tests/test_theme_spell_weighting.py
index e95d60b..637940a 100644
--- a/code/tests/test_theme_spell_weighting.py
+++ b/code/tests/test_theme_spell_weighting.py
@@ -47,10 +47,10 @@ class DummySpellBuilder(SpellAdditionMixin):
def rng(self) -> DummyRNG:
return self._rng
- def get_theme_context(self) -> ThemeContext: # type: ignore[override]
+ def get_theme_context(self) -> ThemeContext:
return self._theme_context
- def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override]
+ def add_card(self, name: str, **kwargs: Any) -> None:
self.card_library[name] = {"Count": kwargs.get("count", 1)}
self.added_cards.append(name)
diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py
index 703dd9f..655f081 100644
--- a/code/tests/test_web_new_deck_partner.py
+++ b/code/tests/test_web_new_deck_partner.py
@@ -20,7 +20,7 @@ def _fresh_client() -> TestClient:
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
clear_commander_catalog_cache()
- from code.web.app import app # type: ignore
+ from code.web.app import app
client = TestClient(app)
from code.web.services import tasks
diff --git a/code/type_definitions_theme_catalog.py b/code/type_definitions_theme_catalog.py
index da88ae0..dbcae13 100644
--- a/code/type_definitions_theme_catalog.py
+++ b/code/type_definitions_theme_catalog.py
@@ -87,7 +87,7 @@ class ThemeCatalog(BaseModel):
def theme_names(self) -> List[str]: # convenience
return [t.theme for t in self.themes]
- def model_post_init(self, __context: Any) -> None: # type: ignore[override]
+ def model_post_init(self, __context: Any) -> None:
# If only legacy 'provenance' provided, alias to metadata_info
if self.metadata_info is None and self.provenance is not None:
object.__setattr__(self, 'metadata_info', self.provenance)
@@ -135,7 +135,7 @@ class ThemeYAMLFile(BaseModel):
model_config = ConfigDict(extra='forbid')
- def model_post_init(self, __context: Any) -> None: # type: ignore[override]
+ def model_post_init(self, __context: Any) -> None:
if not self.metadata_info and self.provenance:
object.__setattr__(self, 'metadata_info', self.provenance)
if self.metadata_info and self.provenance:
diff --git a/code/web/app.py b/code/web/app.py
index ac2854b..77f4f7c 100644
--- a/code/web/app.py
+++ b/code/web/app.py
@@ -19,9 +19,12 @@ from contextlib import asynccontextmanager
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
from tagging.multi_face_merger import load_merge_summary
from .services.combo_utils import detect_all as _detect_all
-from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore
-from .services.commander_catalog_loader import load_commander_catalog # type: ignore
-from .services.tasks import get_session, new_sid, set_session_value # type: ignore
+from .services.theme_catalog_loader import prewarm_common_filters, load_index
+from .services.commander_catalog_loader import load_commander_catalog
+from .services.tasks import get_session, new_sid, set_session_value
+
+# Logger for app-level logging
+logger = logging.getLogger(__name__)
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@@ -53,18 +56,18 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
except Exception:
pass
try:
- commanders_routes.prewarm_default_page() # type: ignore[attr-defined]
+ commanders_routes.prewarm_default_page()
except Exception:
pass
# Warm preview card index once (updated Phase A: moved to card_index module)
try: # local import to avoid cost if preview unused
- from .services.card_index import maybe_build_index # type: ignore
+ from .services.card_index import maybe_build_index
maybe_build_index()
except Exception:
pass
# Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing)
try:
- from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore
+ from .routes.card_browser import get_theme_catalog, get_theme_index
get_theme_catalog() # Fast: just reads CSV
get_theme_index() # Slower: parses cards for theme-to-card mapping
except Exception:
@@ -73,7 +76,7 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
try:
from code.settings import ENABLE_CARD_DETAILS
if ENABLE_CARD_DETAILS:
- from .routes.card_browser import get_similarity # type: ignore
+ from .routes.card_browser import get_similarity
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
except Exception:
pass
@@ -86,7 +89,7 @@ app.add_middleware(GZipMiddleware, minimum_size=500)
# Mount static if present
if _STATIC_DIR.exists():
class CacheStatic(StaticFiles):
- async def get_response(self, path, scope): # type: ignore[override]
+ async def get_response(self, path, scope):
resp = await super().get_response(path, scope)
try:
# Add basic cache headers for static assets
@@ -99,12 +102,38 @@ 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.
_orig_template_response = templates.TemplateResponse
-def _compat_template_response(*args, **kwargs): # type: ignore[override]
+def _compat_template_response(*args, **kwargs):
try:
if args and isinstance(args[0], str):
name = args[0]
@@ -122,7 +151,7 @@ def _compat_template_response(*args, **kwargs): # type: ignore[override]
pass
return _orig_template_response(*args, **kwargs)
-templates.TemplateResponse = _compat_template_response # type: ignore[assignment]
+templates.TemplateResponse = _compat_template_response
# (Startup prewarm moved to lifespan handler _lifespan)
@@ -298,7 +327,7 @@ templates.env.globals.update({
# Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev'
def _load_catalog_hash() -> str:
try: # local import to avoid circular on early load
- from .services.theme_catalog_loader import CATALOG_JSON # type: ignore
+ from .services.theme_catalog_loader import CATALOG_JSON
if CATALOG_JSON.exists():
raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
meta = raw.get("metadata_info") or {}
@@ -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():
@@ -916,7 +951,7 @@ async def status_random_theme_stats():
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
try:
- from deck_builder.random_entrypoint import get_theme_tag_stats # type: ignore
+ from deck_builder.random_entrypoint import get_theme_tag_stats
stats = get_theme_tag_stats()
return JSONResponse({"ok": True, "stats": stats})
@@ -1003,8 +1038,8 @@ async def api_random_build(request: Request):
except Exception:
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Import on-demand to avoid heavy costs at module import time
- from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore
- from deck_builder.random_entrypoint import RandomThemeNoMatchError # type: ignore
+ from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError
+ from deck_builder.random_entrypoint import RandomThemeNoMatchError
res = build_random_deck(
theme=theme,
@@ -1135,7 +1170,7 @@ async def api_random_full_build(request: Request):
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Build a full deck deterministically
- from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore
+ from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError
res = build_random_full_deck(
theme=theme,
constraints=constraints,
@@ -1359,7 +1394,7 @@ async def api_random_reroll(request: Request):
except Exception:
new_seed = None
if new_seed is None:
- from random_util import generate_seed # type: ignore
+ from random_util import generate_seed
new_seed = int(generate_seed())
# Build with the new seed
@@ -1370,7 +1405,7 @@ async def api_random_reroll(request: Request):
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
- from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
+ from deck_builder.random_entrypoint import build_random_full_deck
res = build_random_full_deck(
theme=theme,
constraints=constraints,
@@ -1751,10 +1786,10 @@ async def hx_random_reroll(request: Request):
except Exception:
new_seed = None
if new_seed is None:
- from random_util import generate_seed # type: ignore
+ from random_util import generate_seed
new_seed = int(generate_seed())
# Import outside conditional to avoid UnboundLocalError when branch not taken
- from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
+ from deck_builder.random_entrypoint import build_random_full_deck
try:
t0 = time.time()
_attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS)
@@ -1765,7 +1800,7 @@ async def hx_random_reroll(request: Request):
_timeout_s = max(0.1, float(_timeout_ms) / 1000.0)
if is_reroll_same:
build_t0 = time.time()
- from headless_runner import run as _run # type: ignore
+ from headless_runner import run as _run
# Suppress builder's internal initial export to control artifact generation (matches full random path logic)
try:
import os as _os
@@ -1778,18 +1813,18 @@ async def hx_random_reroll(request: Request):
summary = None
try:
if hasattr(builder, 'build_deck_summary'):
- summary = builder.build_deck_summary() # type: ignore[attr-defined]
+ summary = builder.build_deck_summary()
except Exception:
summary = None
decklist = []
try:
if hasattr(builder, 'deck_list_final'):
- decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined]
+ decklist = getattr(builder, 'deck_list_final')
except Exception:
decklist = []
# Controlled artifact export (single pass)
- csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined]
- txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined]
+ csv_path = getattr(builder, 'last_csv_path', None)
+ txt_path = getattr(builder, 'last_txt_path', None)
compliance = None
try:
import os as _os
@@ -1797,7 +1832,7 @@ async def hx_random_reroll(request: Request):
# Perform exactly one export sequence now
if not csv_path and hasattr(builder, 'export_decklist_csv'):
try:
- csv_path = builder.export_decklist_csv() # type: ignore[attr-defined]
+ csv_path = builder.export_decklist_csv()
except Exception:
csv_path = None
if csv_path and isinstance(csv_path, str):
@@ -1807,7 +1842,7 @@ async def hx_random_reroll(request: Request):
try:
base_name = _os.path.basename(base_path) + '.txt'
if hasattr(builder, 'export_decklist_text'):
- txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined]
+ txt_path = builder.export_decklist_text(filename=base_name)
except Exception:
# Fallback: if a txt already exists from a prior build reuse it
if _os.path.isfile(base_path + '.txt'):
@@ -1822,7 +1857,7 @@ async def hx_random_reroll(request: Request):
else:
try:
if hasattr(builder, 'compute_and_print_compliance'):
- compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
+ compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path))
except Exception:
compliance = None
if summary:
@@ -2016,7 +2051,7 @@ async def hx_random_reroll(request: Request):
except Exception:
_permalink = None
resp = templates.TemplateResponse(
- "partials/random_result.html", # type: ignore
+ "partials/random_result.html",
{
"request": request,
"seed": int(res.seed),
@@ -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:
@@ -2423,7 +2467,7 @@ async def logs_page(
# Respect feature flag
raise HTTPException(status_code=404, detail="Not Found")
# Reuse status_logs logic
- data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type]
+ data = await status_logs(tail=tail, q=q, level=level)
lines: list[str]
if isinstance(data, JSONResponse):
payload = data.body
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..c9c9090 100644
--- a/code/web/routes/build.py
+++ b/code/web/routes/build.py
@@ -25,11 +25,12 @@ 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
from ..services import orchestrator as orch
-from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore
+from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale
from ..services.build_utils import owned_names as owned_names_helper
from ..services.tasks import get_session, new_sid
from html import escape as _esc
@@ -118,7 +119,7 @@ def _available_cards_normalized() -> tuple[set[str], dict[str, str]]:
from deck_builder.include_exclude_utils import normalize_punctuation
except Exception:
# Fallback: identity normalization
- def normalize_punctuation(x: str) -> str: # type: ignore
+ def normalize_punctuation(x: str) -> str:
return str(x).strip().casefold()
norm_map: dict[str, str] = {}
for name in names:
@@ -469,7 +470,7 @@ def _background_options_from_commander_catalog() -> list[dict[str, Any]]:
seen: set[str] = set()
options: list[dict[str, Any]] = []
- for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined]
+ for record in getattr(catalog, "entries", ()):
if not getattr(record, "is_background", False):
continue
name = getattr(record, "display_name", None)
@@ -1107,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse:
if q_commander:
# Persist a human-friendly commander name into session for the wizard
sess["commander"] = str(q_commander)
+ # Set flag to indicate this is a quick-build scenario
+ sess["quick_build"] = True
except Exception:
pass
return_url = None
@@ -1146,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse:
last_step = 2
else:
last_step = 1
+ # Only pass commander to template if coming from commander browser (?commander= query param)
+ # This prevents stale commander from being pre-filled on subsequent builds
+ # The query param only exists on initial navigation from commander browser
+ should_auto_fill = q_commander is not None
+
resp = templates.TemplateResponse(
request,
"build/index.html",
{
"sid": sid,
- "commander": sess.get("commander"),
+ "commander": sess.get("commander") if should_auto_fill else None,
"tags": sess.get("tags", []),
"name": sess.get("custom_export_base"),
"last_step": last_step,
@@ -1349,6 +1357,19 @@ async def build_new_modal(request: Request) -> HTMLResponse:
for key in skip_keys:
sess.pop(key, None)
+ # M2: Check if this is a quick-build scenario (from commander browser)
+ # Use the quick_build flag set by /build route when ?commander= param present
+ is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
+
+ # M2: Clear commander and form selections for fresh start (unless quick build)
+ if not is_quick_build:
+ 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,
@@ -1361,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": {
+ "commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"),
@@ -1483,20 +1505,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:
@@ -2849,7 +2865,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
snap = h.get("snapshot")
break
if snap is not None:
- orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined]
+ orch._restore_builder(ctx["builder"], snap)
ctx["idx"] = int(target_i) - 1
ctx["last_visible_idx"] = int(target_i) - 1
except Exception:
@@ -2907,6 +2923,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 +2961,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 +3302,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",
{
@@ -3782,7 +3869,7 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
if not ctx or not ctx.get("snapshot"):
return await build_step5_get(request)
try:
- orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined]
+ orch._restore_builder(ctx["builder"], ctx["snapshot"])
except Exception:
return await build_step5_get(request)
# Re-render step 5 with cleared added list
@@ -3844,6 +3931,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
@@ -4196,7 +4293,7 @@ async def build_alternatives(
try:
if rng is not None:
return rng.sample(seq, limit) if len(seq) >= limit else list(seq)
- import random as _rnd # type: ignore
+ import random as _rnd
return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq)
except Exception:
return list(seq[:limit])
@@ -4247,7 +4344,7 @@ async def build_alternatives(
# Helper: map display names
def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
try:
- return builder_display_map(b, lower_pool) # type: ignore[arg-type]
+ return builder_display_map(b, lower_pool)
except Exception:
return {nm: nm for nm in lower_pool}
@@ -4425,7 +4522,7 @@ async def build_alternatives(
pass
# Sort by priority like the builder
try:
- pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type]
+ pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"])
except Exception:
pass
# Exclusions and ownership (for non-random roles this stays before slicing)
@@ -4923,13 +5020,13 @@ async def build_compliance_panel(request: Request) -> HTMLResponse:
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
- comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
+ comp = orch._attach_enforcement_plan(b, comp)
except Exception:
pass
if not comp:
@@ -5054,11 +5151,11 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
# If missing, export once to establish base
if not base_stem:
try:
- ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
+ ctx["csv_path"] = b.export_decklist_csv()
import os as _os
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
# Also produce a text export for completeness
- ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined]
+ ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt')
except Exception:
base_stem = None
# Add lock placeholders into the library before enforcement so user choices are present
@@ -5103,7 +5200,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
pass
# Run enforcement + re-exports (tops up to 100 internally)
try:
- rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined]
+ rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto')
except Exception as e:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
@@ -5177,13 +5274,13 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
- comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
+ comp = orch._attach_enforcement_plan(b, comp)
except Exception:
pass
try:
diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py
index 957936b..9b4f290 100644
--- a/code/web/routes/decks.py
+++ b/code/web/routes/decks.py
@@ -425,7 +425,7 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s
mt_val = str(int(mt))
except Exception:
mt_val = "0"
- options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type]
+ options.append({"name": it.get("name"), "label": label, "mtime": mt_val})
diffs = None
metaA: Dict[str, str] = {}
diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py
index 9cbe635..dc711d4 100644
--- a/code/web/routes/setup.py
+++ b/code/web/routes/setup.py
@@ -7,7 +7,7 @@ from pathlib import Path
import json as _json
from fastapi.responses import HTMLResponse, JSONResponse
from ..app import templates
-from ..services.orchestrator import _ensure_setup_ready # type: ignore
+from ..services.orchestrator import _ensure_setup_ready
router = APIRouter(prefix="/setup")
@@ -21,7 +21,7 @@ def _kickoff_setup_async(force: bool = False):
def runner():
try:
print(f"[SETUP THREAD] Starting setup/tagging (force={force})...")
- _ensure_setup_ready(print, force=force) # type: ignore[arg-type]
+ _ensure_setup_ready(print, force=force)
print("[SETUP THREAD] Setup/tagging completed successfully")
except Exception as e: # pragma: no cover - background best effort
try:
@@ -36,7 +36,7 @@ def _kickoff_setup_async(force: bool = False):
@router.get("/running", response_class=HTMLResponse)
-async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override]
+async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse:
# Optionally start the setup/tagging in the background if requested
try:
if start and int(start) != 0:
@@ -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..4917aa7 100644
--- a/code/web/routes/themes.py
+++ b/code/web/routes/themes.py
@@ -7,7 +7,7 @@ from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import BackgroundTasks
-from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore
+from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from ..services.theme_catalog_loader import (
@@ -17,10 +17,10 @@ from ..services.theme_catalog_loader import (
filter_slugs_fast,
summaries_for_slugs,
)
-from ..services.theme_preview import get_theme_preview # type: ignore
-from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore
-from ..services.theme_preview import preview_metrics # type: ignore
-from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters
+from ..services.theme_preview import get_theme_preview
+from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters
+from ..services.theme_preview import preview_metrics
+from ..services import theme_preview as _theme_preview_mod # for error counters
import os
from fastapi import Body
@@ -36,7 +36,7 @@ router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
# Reuse the main app's template environment so nav globals stay consistent.
try: # circular-safe import: app defines templates before importing this router
- from ..app import templates as _templates # type: ignore
+ from ..app import templates as _templates
except Exception: # Fallback (tests/minimal contexts)
_templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates'))
@@ -131,7 +131,7 @@ async def theme_suggest(
# Optional rate limit using app helper if available
rl_result = None
try:
- from ..app import rate_limit_check # type: ignore
+ from ..app import rate_limit_check
rl_result = rate_limit_check(request, "suggest")
except HTTPException as http_ex: # propagate 429 with headers
raise http_ex
@@ -231,7 +231,7 @@ async def theme_status():
yaml_file_count = 0
if yaml_catalog_exists:
try:
- yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type]
+ yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"])
except Exception:
yaml_file_count = -1
tagged_time = _load_tag_flag_time()
@@ -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():
@@ -569,7 +547,7 @@ async def theme_yaml(theme_id: str):
raise HTTPException(status_code=404, detail="yaml_not_found")
# Reconstruct minimal YAML (we have dict already)
import yaml as _yaml # local import to keep top-level lean
- text = _yaml.safe_dump(y, sort_keys=False) # type: ignore
+ text = _yaml.safe_dump(y, sort_keys=False)
headers = {"Content-Type": "text/plain; charset=utf-8"}
return HTMLResponse(text, headers=headers)
@@ -653,7 +631,7 @@ async def api_theme_search(
prefix: list[dict[str, Any]] = []
substr: list[dict[str, Any]] = []
seen: set[str] = set()
- themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined]
+ themes_iter = list(idx.catalog.themes)
# Phase 1 + 2: exact / prefix
for t in themes_iter:
name = t.theme
@@ -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..8c11c56 100644
--- a/code/web/services/build_utils.py
+++ b/code/web/services/build_utils.py
@@ -202,7 +202,7 @@ def commander_hover_context(
from .summary_utils import format_theme_label, format_theme_list
except Exception:
# Fallbacks in the unlikely event of circular import issues
- def format_theme_label(value: Any) -> str: # type: ignore[redef]
+ def format_theme_label(value: Any) -> str:
text = str(value or "").strip().replace("_", " ")
if not text:
return ""
@@ -214,10 +214,10 @@ def commander_hover_context(
parts.append(chunk[:1].upper() + chunk[1:].lower())
return " ".join(parts)
- def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef]
+ def format_theme_list(values: Iterable[Any]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
- for raw in values or []: # type: ignore[arg-type]
+ for raw in values or []:
label = format_theme_label(raw)
if not label or len(label) <= 1:
continue
@@ -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)):
@@ -403,7 +420,7 @@ def step5_ctx_from_result(
else:
entry = {}
try:
- entry.update(vars(item)) # type: ignore[arg-type]
+ entry.update(vars(item))
except Exception:
pass
# Preserve common attributes when vars() empty
diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py
index c38b78d..654d5ac 100644
--- a/code/web/services/orchestrator.py
+++ b/code/web/services/orchestrator.py
@@ -359,7 +359,7 @@ def _global_prune_disallowed_pool(b: DeckBuilder) -> None:
drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try:
- import pandas as _pd # type: ignore
+ import pandas as _pd
mask_keep = _pd.Series(mask_keep, index=work.index)
except Exception:
pass
@@ -480,7 +480,7 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li
tmp = DeckBuilder()
try:
if hasattr(tmp, '_normalize_commander_query'):
- query = tmp._normalize_commander_query(query) # type: ignore[attr-defined]
+ query = tmp._normalize_commander_query(query)
else:
# Light fallback: basic title case
query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')])
@@ -653,7 +653,7 @@ def commander_select(name: str) -> Dict[str, Any]:
if row.empty:
try:
if hasattr(tmp, '_normalize_commander_query'):
- name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined]
+ name2 = tmp._normalize_commander_query(name)
else:
name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')])
row = df[df["name"] == name2]
@@ -1288,8 +1288,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
pass
# Bust theme-related in-memory caches so new catalog reflects immediately
try:
- from .theme_catalog_loader import bust_filter_cache # type: ignore
- from .theme_preview import bust_preview_cache # type: ignore
+ from .theme_catalog_loader import bust_filter_cache
+ from .theme_preview import bust_preview_cache
bust_filter_cache("catalog_refresh")
bust_preview_cache("catalog_refresh")
try:
@@ -1327,7 +1327,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
try:
# M4 (Parquet Migration): Check for processed Parquet file instead of CSV
- from path_util import get_processed_cards_path # type: ignore
+ from path_util import get_processed_cards_path
cards_path = get_processed_cards_path()
flag_path = os.path.join('csv_files', '.tagging_complete.json')
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
@@ -1416,7 +1416,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0})
try:
- from file_setup.setup import initial_setup # type: ignore
+ from file_setup.setup import initial_setup
# Always run initial_setup when forced or when cards are missing/stale
initial_setup()
except Exception as e:
@@ -1425,7 +1425,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
return
# M4 (Parquet Migration): Use unified run_tagging with parallel support
try:
- from tagging import tagger as _tagger # type: ignore
+ from tagging import tagger as _tagger
use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"}
max_workers_env = os.getenv('WEB_TAG_WORKERS')
try:
@@ -1466,7 +1466,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
try:
_write_status({"running": True, "phase": "aggregating", "message": "Consolidating card data...", "percent": 90})
out("Aggregating card CSVs into Parquet files...")
- from file_setup.card_aggregator import CardAggregator # type: ignore
+ from file_setup.card_aggregator import CardAggregator
aggregator = CardAggregator()
# Aggregate all_cards.parquet
@@ -1474,7 +1474,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
out(f"Aggregated {stats['total_cards']} cards into all_cards.parquet ({stats['file_size_mb']} MB)")
# Convert commander_cards.csv and background_cards.csv to Parquet
- import pandas as pd # type: ignore
+ import pandas as pd
# Convert commander_cards.csv
commander_csv = 'csv_files/commander_cards.csv'
@@ -1524,8 +1524,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
# Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress
_refresh_theme_catalog(out, force=True, fast_path=False)
try:
- from .theme_catalog_loader import bust_filter_cache # type: ignore
- from .theme_preview import bust_preview_cache # type: ignore
+ from .theme_catalog_loader import bust_filter_cache
+ from .theme_preview import bust_preview_cache
bust_filter_cache("tagging_complete")
bust_preview_cache("tagging_complete")
except Exception:
@@ -1721,19 +1721,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Owned/Prefer-owned integration (optional for headless runs)
try:
if use_owned_only:
- b.use_owned_only = True # type: ignore[attr-defined]
+ b.use_owned_only = True
# Prefer explicit owned_names list if provided; else let builder discover from files
if owned_names:
try:
- b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
+ b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception:
- b.owned_card_names = set() # type: ignore[attr-defined]
+ b.owned_card_names = set()
# Soft preference flag does not filter; only biases selection order
if prefer_owned:
try:
- b.prefer_owned = True # type: ignore[attr-defined]
+ b.prefer_owned = True
if owned_names and not getattr(b, 'owned_card_names', None):
- b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
+ b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception:
pass
except Exception:
@@ -1751,13 +1751,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Thread combo preferences (if provided)
try:
if prefer_combos is not None:
- b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined]
+ b.prefer_combos = bool(prefer_combos)
if combo_target_count is not None:
- b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined]
+ b.combo_target_count = int(combo_target_count)
if combo_balance:
bal = str(combo_balance).strip().lower()
if bal in ('early','late','mix'):
- b.combo_balance = bal # type: ignore[attr-defined]
+ b.combo_balance = bal
except Exception:
pass
@@ -1934,7 +1934,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception:
pass
if hasattr(b, 'export_decklist_csv'):
- csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
+ csv_path = b.export_decklist_csv()
except Exception as e:
out(f"CSV export failed: {e}")
try:
@@ -1942,7 +1942,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Try to mirror build_deck_full behavior by displaying the contents
import os as _os
base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "")
- txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
+ txt_path = b.export_decklist_text(filename=base + '.txt')
try:
b._display_txt_contents(txt_path)
except Exception:
@@ -1950,7 +1950,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
- rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
+ rep0 = b.compute_and_print_compliance(base_stem=base)
# Attach planning preview (no mutation) and only auto-enforce if explicitly enabled
rep0 = _attach_enforcement_plan(b, rep0)
try:
@@ -1959,7 +1959,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
- b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
+ b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception:
pass
# Load compliance JSON for UI consumption
@@ -1981,7 +1981,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Build structured summary for UI
try:
if hasattr(b, 'build_deck_summary'):
- summary = b.build_deck_summary() # type: ignore[attr-defined]
+ summary = b.build_deck_summary()
except Exception:
summary = None
# Write sidecar summary JSON next to CSV (if available)
@@ -1999,7 +1999,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
"txt": txt_path,
}
try:
- commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
+ commander_meta = b.get_commander_export_metadata()
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
@@ -2383,21 +2383,21 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) ->
"""Attach combined commander metadata to the builder."""
try:
- builder.combined_commander = combined # type: ignore[attr-defined]
+ builder.combined_commander = combined
except Exception:
pass
try:
- builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined]
+ builder.partner_mode = getattr(combined, "partner_mode", None)
except Exception:
pass
try:
- builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined]
+ builder.secondary_commander = getattr(combined, "secondary_name", None)
except Exception:
pass
try:
- builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined]
- builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined]
- builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined]
+ builder.combined_color_identity = getattr(combined, "color_identity", None)
+ builder.combined_theme_tags = getattr(combined, "theme_tags", None)
+ builder.partner_warnings = getattr(combined, "warnings", None)
except Exception:
pass
commander_dict = getattr(builder, "commander_dict", None)
@@ -2583,17 +2583,17 @@ def start_build_ctx(
# Owned-only / prefer-owned (if requested)
try:
if use_owned_only:
- b.use_owned_only = True # type: ignore[attr-defined]
+ b.use_owned_only = True
if owned_names:
try:
- b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
+ b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception:
- b.owned_card_names = set() # type: ignore[attr-defined]
+ b.owned_card_names = set()
if prefer_owned:
try:
- b.prefer_owned = True # type: ignore[attr-defined]
+ b.prefer_owned = True
if owned_names and not getattr(b, 'owned_card_names', None):
- b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
+ b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip())
except Exception:
pass
except Exception:
@@ -2646,14 +2646,14 @@ def start_build_ctx(
# Thread combo config
try:
if combo_target_count is not None:
- b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined]
+ b.combo_target_count = int(combo_target_count)
except Exception:
pass
try:
if combo_balance:
bal = str(combo_balance).strip().lower()
if bal in ('early','late','mix'):
- b.combo_balance = bal # type: ignore[attr-defined]
+ b.combo_balance = bal
except Exception:
pass
# Stages
@@ -2735,23 +2735,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try:
- ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
+ ctx["csv_path"] = b.export_decklist_csv()
except Exception as e:
logs.append(f"CSV export failed: {e}")
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try:
import os as _os
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
- ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
+ ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt')
# Export the run configuration JSON for manual builds
try:
- b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
+ b.export_run_config_json(directory='config', filename=base + '.json')
except Exception:
pass
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
- rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
+ rep0 = b.compute_and_print_compliance(base_stem=base)
rep0 = _attach_enforcement_plan(b, rep0)
try:
import os as __os
@@ -2759,7 +2759,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
- b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
+ b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception:
pass
# Load compliance JSON for UI consumption
@@ -2811,7 +2811,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
summary = None
try:
if hasattr(b, 'build_deck_summary'):
- summary = b.build_deck_summary() # type: ignore[attr-defined]
+ summary = b.build_deck_summary()
except Exception:
summary = None
# Write sidecar summary JSON next to CSV (if available)
@@ -2830,7 +2830,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"txt": ctx.get("txt_path"),
}
try:
- commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
+ commander_meta = b.get_commander_export_metadata()
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
@@ -2890,12 +2890,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp_now = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp_now = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp_now = None
try:
if comp_now:
- comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined]
+ comp_now = _attach_enforcement_plan(b, comp_now)
except Exception:
pass
# If still FAIL, return the saved result without advancing or rerunning
@@ -3407,7 +3407,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
@@ -3508,7 +3508,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
@@ -3575,7 +3575,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
- comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
+ comp = b.compute_and_print_compliance(base_stem=None)
except Exception:
comp = None
try:
@@ -3617,23 +3617,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try:
- ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
+ ctx["csv_path"] = b.export_decklist_csv()
except Exception as e:
logs.append(f"CSV export failed: {e}")
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try:
import os as _os
base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv"))
- ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
+ ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt')
# Export the run configuration JSON for manual builds
try:
- b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
+ b.export_run_config_json(directory='config', filename=base + '.json')
except Exception:
pass
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
- rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
+ rep0 = b.compute_and_print_compliance(base_stem=base)
rep0 = _attach_enforcement_plan(b, rep0)
try:
import os as __os
@@ -3641,7 +3641,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
- b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
+ b.enforce_and_reexport(base_stem=base, mode='auto')
except Exception:
pass
# Load compliance JSON for UI consumption
@@ -3662,7 +3662,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
summary = None
try:
if hasattr(b, 'build_deck_summary'):
- summary = b.build_deck_summary() # type: ignore[attr-defined]
+ summary = b.build_deck_summary()
except Exception:
summary = None
# Write sidecar summary JSON next to CSV (if available)
@@ -3681,7 +3681,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"txt": ctx.get("txt_path"),
}
try:
- commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined]
+ commander_meta = b.get_commander_export_metadata()
except Exception:
commander_meta = {}
names = commander_meta.get("commander_names") or []
diff --git a/code/web/services/partner_suggestions.py b/code/web/services/partner_suggestions.py
index 91eb97e..b781ef5 100644
--- a/code/web/services/partner_suggestions.py
+++ b/code/web/services/partner_suggestions.py
@@ -362,7 +362,7 @@ def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[Part
if allow_auto_refresh:
_DATASET_REFRESH_ATTEMPTED = True
try:
- from .orchestrator import _maybe_refresh_partner_synergy # type: ignore
+ from .orchestrator import _maybe_refresh_partner_synergy
_maybe_refresh_partner_synergy(None, force=True)
except Exception as refresh_exc: # pragma: no cover - best-effort
diff --git a/code/web/services/preview_cache.py b/code/web/services/preview_cache.py
index 2f2b368..b93a688 100644
--- a/code/web/services/preview_cache.py
+++ b/code/web/services/preview_cache.py
@@ -21,7 +21,7 @@ import json
import threading
import math
-from .preview_metrics import record_eviction # type: ignore
+from .preview_metrics import record_eviction
# Phase 2 extraction: adaptive TTL band policy moved into preview_policy
from .preview_policy import (
@@ -30,7 +30,7 @@ from .preview_policy import (
DEFAULT_TTL_MIN as _POLICY_TTL_MIN,
DEFAULT_TTL_MAX as _POLICY_TTL_MAX,
)
-from .preview_cache_backend import redis_store # type: ignore
+from .preview_cache_backend import redis_store
TTL_SECONDS = 600
# Backward-compat variable names retained (tests may reference) mapping to policy constants
diff --git a/code/web/services/preview_cache_backend.py b/code/web/services/preview_cache_backend.py
index 3750d22..d24d635 100644
--- a/code/web/services/preview_cache_backend.py
+++ b/code/web/services/preview_cache_backend.py
@@ -24,9 +24,9 @@ import os
import time
try: # lazy optional dependency
- import redis # type: ignore
+ import redis
except Exception: # pragma: no cover - absence path
- redis = None # type: ignore
+ redis = None
_URL = os.getenv("THEME_PREVIEW_REDIS_URL")
_DISABLED = (os.getenv("THEME_PREVIEW_REDIS_DISABLE") or "").lower() in {"1","true","yes","on"}
@@ -42,7 +42,7 @@ def _init() -> None:
_INIT_ERR = "disabled_or_missing"
return
try:
- _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore
+ _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25)
# lightweight ping (non-fatal)
try:
_CLIENT.ping()
@@ -86,7 +86,7 @@ def redis_get(key: Tuple[str, int, str | None, str | None, str]) -> Optional[Dic
return None
try:
skey = "tpv:" + "|".join([str(part) for part in key])
- raw: bytes | None = _CLIENT.get(skey) # type: ignore
+ raw: bytes | None = _CLIENT.get(skey)
if not raw:
return None
obj = json.loads(raw.decode("utf-8"))
diff --git a/code/web/services/sampling.py b/code/web/services/sampling.py
index f7e9aad..40d8a0b 100644
--- a/code/web/services/sampling.py
+++ b/code/web/services/sampling.py
@@ -130,7 +130,7 @@ def sample_real_cards_for_theme(theme: str, limit: int, colors_filter: Optional[
if allow_splash:
off = ci - commander_colors
if len(off) == 1:
- c["_splash_off_color"] = True # type: ignore
+ c["_splash_off_color"] = True
new_pool.append(c)
continue
pool = new_pool
diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py
index aee1a3f..4bb10eb 100644
--- a/code/web/services/summary_utils.py
+++ b/code/web/services/summary_utils.py
@@ -7,7 +7,7 @@ from .combo_utils import detect_for_summary as _detect_for_summary
def _owned_set_helper() -> set[str]:
try:
- from .build_utils import owned_set as _owned_set # type: ignore
+ from .build_utils import owned_set as _owned_set
return _owned_set()
except Exception:
@@ -21,7 +21,7 @@ def _owned_set_helper() -> set[str]:
def _sanitize_tag_list(values: Iterable[Any]) -> List[str]:
cleaned: List[str] = []
- for raw in values or []: # type: ignore[arg-type]
+ for raw in values or []:
text = str(raw or "").strip()
if not text:
continue
@@ -78,7 +78,7 @@ def format_theme_label(raw: Any) -> str:
def format_theme_list(values: Iterable[Any]) -> List[str]:
seen: set[str] = set()
result: List[str] = []
- for raw in values or []: # type: ignore[arg-type]
+ for raw in values or []:
label = format_theme_label(raw)
if not label:
continue
diff --git a/code/web/services/theme_catalog_loader.py b/code/web/services/theme_catalog_loader.py
index 9212b78..e7c6247 100644
--- a/code/web/services/theme_catalog_loader.py
+++ b/code/web/services/theme_catalog_loader.py
@@ -26,10 +26,10 @@ from pydantic import BaseModel
# - Docker (WORKDIR /app/code): modules also available top-level.
# - Package/zip installs (rare): may require 'code.' prefix.
try:
- from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore
+ from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry
except ImportError: # pragma: no cover - fallback path
try:
- from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore
+ from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry
except ImportError: # pragma: no cover - last resort (avoid beyond top-level relative import)
raise
@@ -97,7 +97,7 @@ def _needs_reload() -> bool:
if not CATALOG_JSON.exists():
return bool(_CACHE)
mtime = CATALOG_JSON.stat().st_mtime
- idx: SlugThemeIndex | None = _CACHE.get("index") # type: ignore
+ idx: SlugThemeIndex | None = _CACHE.get("index")
if idx is None:
return True
if mtime > idx.mtime:
@@ -121,7 +121,7 @@ def _needs_reload() -> bool:
# Fast path: use os.scandir for lower overhead vs Path.glob
newest = 0.0
try:
- with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type]
+ with _os.scandir(YAML_DIR) as it:
for entry in it:
if entry.is_file() and entry.name.endswith('.yml'):
try:
@@ -164,7 +164,7 @@ def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str:
def load_index() -> SlugThemeIndex:
if not _needs_reload():
- return _CACHE["index"] # type: ignore
+ return _CACHE["index"]
if not CATALOG_JSON.exists():
raise FileNotFoundError("theme_list.json missing")
raw = json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
@@ -220,7 +220,7 @@ def validate_catalog_integrity(rebuild: bool = True) -> Dict[str, Any]:
out.update({"ok": False, "error": f"read_error:{e}"})
return out
# Recompute hash using same heuristic as build script
- from scripts.build_theme_catalog import load_catalog_yaml # type: ignore
+ from scripts.build_theme_catalog import load_catalog_yaml
try:
yaml_catalog = load_catalog_yaml(verbose=False) # keyed by display_name
except Exception:
@@ -495,7 +495,7 @@ def prewarm_common_filters(max_archetypes: int = 12) -> None:
# Gather archetypes & buckets (limited)
archetypes: List[str] = []
try:
- archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] # type: ignore[arg-type]
+ archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes]
except Exception:
archetypes = []
buckets = ["Very Common", "Common", "Uncommon", "Niche", "Rare"]
diff --git a/code/web/services/theme_preview.py b/code/web/services/theme_preview.py
index d1d3991..cc406af 100644
--- a/code/web/services/theme_preview.py
+++ b/code/web/services/theme_preview.py
@@ -17,7 +17,7 @@ import json
try:
import yaml # type: ignore
except Exception: # pragma: no cover - PyYAML already in requirements; defensive
- yaml = None # type: ignore
+ yaml = None
from .preview_metrics import (
record_build_duration,
record_role_counts,
@@ -51,8 +51,8 @@ from .preview_cache import (
store_cache_entry,
evict_if_needed,
)
-from .preview_cache_backend import redis_get # type: ignore
-from .preview_metrics import record_redis_get, record_redis_store # type: ignore
+from .preview_cache_backend import redis_get
+from .preview_metrics import record_redis_get, record_redis_store
# Local alias to maintain existing internal variable name usage
_PREVIEW_CACHE = PREVIEW_CACHE
@@ -66,7 +66,7 @@ __all__ = ["get_theme_preview", "preview_metrics", "bust_preview_cache"]
## (duplicate imports removed)
# Legacy constant alias retained for any external references; now a function in cache module.
-TTL_SECONDS = ttl_seconds # type: ignore
+TTL_SECONDS = ttl_seconds
# Per-theme error histogram (P2 observability)
_PREVIEW_PER_THEME_ERRORS: Dict[str, int] = {}
@@ -89,7 +89,7 @@ def _load_curated_synergy_matrix() -> None:
# Expect top-level key 'pairs' but allow raw mapping
pairs = data.get('pairs', data)
if isinstance(pairs, dict):
- _CURATED_SYNERGY_MATRIX = pairs # type: ignore
+ _CURATED_SYNERGY_MATRIX = pairs
else:
_CURATED_SYNERGY_MATRIX = None
else:
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/app.js b/code/web/static/js_backup_pre_typescript/app.js
similarity index 100%
rename from code/web/static/app.js
rename to code/web/static/js_backup_pre_typescript/app.js
diff --git a/code/web/static/js_backup_pre_typescript/components.js b/code/web/static/js_backup_pre_typescript/components.js
new file mode 100644
index 0000000..de4021c
--- /dev/null
+++ b/code/web/static/js_backup_pre_typescript/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/shared-components.css b/code/web/static/shared-components.css
new file mode 100644
index 0000000..986f565
--- /dev/null
+++ b/code/web/static/shared-components.css
@@ -0,0 +1,655 @@
+/* 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: var(--panel);
+ 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;
+ border-right: 1px solid var(--border);
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ color: var(--text);
+ background: var(--bg);
+}
+
+.include-textarea::placeholder {
+ color: var(--muted);
+ 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;
+ border-right: 1px solid var(--border);
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ color: var(--text);
+ background: var(--bg);
+}
+
+.exclude-textarea::placeholder {
+ color: var(--muted);
+ 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: var(--panel);
+ 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: var(--panel);
+ color: var(--text);
+ 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: var(--panel);
+ 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: var(--panel);
+ font-weight: 600;
+ transition: background-color 0.15s ease;
+}
+
+.combo-summary:hover {
+ background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
+ border-color: var(--text);
+}
+
+/* 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: var(--panel);
+}
+
+/* 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..d0593a6 100644
--- a/code/web/static/styles.css
+++ b/code/web/static/styles.css
@@ -1,738 +1,2848 @@
+/* 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;
+ /* warm beige background (keep existing) */
+ --panel: #ebe5d8;
+ /* lighter warm cream - more contrast with bg, subtle panels */
+ --text: #0d0a08;
+ /* very dark brown/near-black for strong readability */
+ --muted: #5a544c;
+ /* darker muted brown for better contrast */
+ --border: #bfb5a3;
+ /* darker warm-gray border for better definition */
+ /* Navbar/banner: darker warm brown for hierarchy */
+ --surface-banner: #9b8f7a;
+ /* warm medium brown - darker than panels, lighter than dark theme */
+ --surface-sidebar: #9b8f7a;
+ /* match banner for consistency */
+ --surface-banner-text: #1a1410;
+ /* dark brown text on medium brown bg */
+ --surface-sidebar-text: #1a1410;
+ /* dark brown text on medium brown bg */
+ /* Button colors: use taupe for buttons so they stand out from light panels */
+ --btn-bg: #d4cbb8;
+ /* medium warm taupe - stands out against light panels */
+ --btn-text: #1a1410;
+ /* dark brown text */
+ --btn-hover-bg: #c4b9a5;
+ /* darker taupe on hover */
}
[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;
+}
+
+/* Range slider styling */
+
+input[type="range"]{
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 8px;
+ background: var(--bg);
+ border-radius: 4px;
+ outline: none;
+ border: 1px solid var(--border);
+}
+
+input[type="range"]::-webkit-slider-thumb{
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ background: var(--blue-main);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--panel);
+ box-shadow: 0 2px 4px rgba(0,0,0,.2);
+}
+
+input[type="range"]::-moz-range-thumb{
+ width: 20px;
+ height: 20px;
+ background: var(--blue-main);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--panel);
+ box-shadow: 0 2px 4px rgba(0,0,0,.2);
+}
+
+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: var(--panel);
+ color:var(--text);
+ 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: var(--btn-bg, #1a1d24);
+ color: var(--btn-text, #e8e8e8);
+ border-color: var(--border);
+}
+
+.home-button.btn-secondary:hover {
+ background: var(--btn-hover-bg, #22252d);
+ border-color: var(--border);
+}
+
+.home-button.btn-primary {
+ background: var(--blue-main);
+ color: white;
+ border-color: var(--blue-main);
+}
+
+.home-button.btn-primary:hover {
+ background: #0c5aa6;
+ border-color: #0c5aa6;
+}
/* 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:var(--panel);
+ color:var(--text);
+ 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:var(--bg);
+ 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: var(--panel);
+ 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: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
+ border-color: var(--text);
}
.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 +2850,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: var(--panel);
+ 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..f8d085c
--- /dev/null
+++ b/code/web/static/tailwind.css
@@ -0,0 +1,3537 @@
+/* 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; /* warm beige background (keep existing) */
+ --panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */
+ --text: #0d0a08; /* very dark brown/near-black for strong readability */
+ --muted: #5a544c; /* darker muted brown for better contrast */
+ --border: #bfb5a3; /* darker warm-gray border for better definition */
+ /* Navbar/banner: darker warm brown for hierarchy */
+ --surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */
+ --surface-sidebar: #9b8f7a; /* match banner for consistency */
+ --surface-banner-text: #1a1410; /* dark brown text on medium brown bg */
+ --surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */
+ /* Button colors: use taupe for buttons so they stand out from light panels */
+ --btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */
+ --btn-text: #1a1410; /* dark brown text */
+ --btn-hover-bg: #c4b9a5; /* darker taupe on hover */
+}
+
+[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; }
+/* Range slider styling */
+input[type="range"]{
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 8px;
+ background: var(--bg);
+ border-radius: 4px;
+ outline: none;
+ border: 1px solid var(--border);
+}
+input[type="range"]::-webkit-slider-thumb{
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ background: var(--blue-main);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--panel);
+ box-shadow: 0 2px 4px rgba(0,0,0,.2);
+}
+input[type="range"]::-moz-range-thumb{
+ width: 20px;
+ height: 20px;
+ background: var(--blue-main);
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid var(--panel);
+ box-shadow: 0 2px 4px rgba(0,0,0,.2);
+}
+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: var(--panel); color:var(--text); 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: var(--btn-bg, #1a1d24);
+ color: var(--btn-text, #e8e8e8);
+ border-color: var(--border);
+}
+.home-button.btn-secondary:hover {
+ background: var(--btn-hover-bg, #22252d);
+ border-color: var(--border);
+}
+.home-button.btn-primary {
+ background: var(--blue-main);
+ color: white;
+ border-color: var(--blue-main);
+}
+.home-button.btn-primary:hover {
+ background: #0c5aa6;
+ border-color: #0c5aa6;
+}
+
+/* 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:var(--panel);
+ color:var(--text);
+ 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:var(--bg); 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: var(--panel);
+ 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: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
+ border-color: var(--text);
+}
+
+.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: var(--panel);
+ 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/static/ts/app.ts b/code/web/static/ts/app.ts
new file mode 100644
index 0000000..3e276eb
--- /dev/null
+++ b/code/web/static/ts/app.ts
@@ -0,0 +1,1702 @@
+/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
+// Type definitions moved inline to avoid module system
+interface StateManager {
+ get(key: string, def?: any): any;
+ set(key: string, val: any): void;
+ inHash(obj: Record): void;
+ readHash(): URLSearchParams;
+}
+
+interface ToastOptions {
+ duration?: number;
+}
+
+interface TelemetryManager {
+ send(eventName: string, data?: Record): void;
+}
+
+interface SkeletonManager {
+ show(context?: HTMLElement | Document): void;
+ hide(context?: HTMLElement | Document): void;
+}
+
+(function(){
+ // Design tokens fallback (in case CSS variables missing in older browsers)
+ // No-op here since styles.css defines variables; kept for future JS reads.
+
+ // State persistence helpers (localStorage + URL hash)
+ const state: StateManager = {
+ get: function(key: string, def?: any): any {
+ try { const v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
+ },
+ set: function(key: string, val: any): void {
+ try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
+ },
+ inHash: function(obj: Record): void {
+ // Merge obj into location.hash as query-like params
+ try {
+ const params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
+ Object.keys(obj||{}).forEach(function(k: string){ params.set(k, obj[k]); });
+ location.hash = params.toString();
+ } catch(e){}
+ },
+ readHash: function(): URLSearchParams {
+ try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
+ }
+ };
+ window.__mtgState = state;
+
+ // Toast system
+ let toastHost: HTMLElement | null = null;
+ function ensureToastHost(): HTMLElement {
+ if (!toastHost){
+ toastHost = document.createElement('div');
+ toastHost.className = 'toast-host';
+ document.body.appendChild(toastHost);
+ }
+ return toastHost;
+ }
+ function toast(msg: string | HTMLElement, type?: string, opts?: ToastOptions): HTMLElement {
+ ensureToastHost();
+ const t = document.createElement('div');
+ t.className = 'toast' + (type ? ' '+type : '');
+ t.setAttribute('role','status');
+ t.setAttribute('aria-live','polite');
+ t.textContent = '';
+ if (typeof msg === 'string') { t.textContent = msg; }
+ else if (msg && msg.nodeType === 1) { t.appendChild(msg); }
+ toastHost!.appendChild(t);
+ const delay = (opts && opts.duration) || 2600;
+ setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
+ return t;
+ }
+ window.toast = toast;
+ function toastHTML(html: string, type?: string, opts?: ToastOptions): HTMLElement {
+ const container = document.createElement('div');
+ container.innerHTML = html;
+ return toast(container, type, opts);
+ }
+ window.toastHTML = toastHTML;
+
+ const telemetryEndpoint: string = (function(): string {
+ if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){
+ return window.__telemetryEndpoint.trim();
+ }
+ return '/telemetry/events';
+ })();
+ const telemetry: TelemetryManager = {
+ send: function(eventName: string, data?: Record): void {
+ if (!telemetryEndpoint || !eventName) return;
+ let payload: string;
+ try {
+ payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() });
+ } catch(_){ return; }
+ try {
+ if (navigator.sendBeacon){
+ const blob = new Blob([payload], { type: 'application/json' });
+ navigator.sendBeacon(telemetryEndpoint, blob);
+ } else if (window.fetch){
+ fetch(telemetryEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: payload,
+ keepalive: true,
+ }).catch(function(){ /* noop */ });
+ }
+ } catch(_){ }
+ }
+ };
+ window.appTelemetry = telemetry;
+
+ // Global HTMX error handling => toast
+ document.addEventListener('htmx:responseError', function(e){
+ const detail = e.detail || {} as any;
+ const xhr = detail.xhr || {} as any;
+ const rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || '';
+ const payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })() as any;
+ const status = payload.status || xhr.status || '';
+ const msg = payload.detail || payload.message || 'Action failed';
+ const path = payload.path || (e && e.detail && e.detail.path) || '';
+ const html = ''+
+ ''+
+ ''+String(msg)+''+ (status? ' ('+status+')' : '')+
+ (rid ? '' : '')+
+ '
'+
+ (rid ? 'Request-ID: '+rid+'
' : '');
+ const t = toastHTML(html, 'error', { duration: 7000 });
+ // Wire Copy
+ const btn = t.querySelector('[data-copy-error]') as HTMLButtonElement;
+ if (btn){
+ btn.addEventListener('click', function(){
+ const lines = [
+ 'Error: '+String(msg),
+ 'Status: '+String(status),
+ 'Path: '+String(path || (xhr.responseURL||'')),
+ 'Request-ID: '+String(rid)
+ ];
+ try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ }
+ });
+ }
+ // Optional inline banner if a surface is available
+ try {
+ const target = e && e.target as HTMLElement;
+ const surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]');
+ if (surface){
+ const banner = document.createElement('div');
+ banner.className = 'inline-error-banner';
+ banner.innerHTML = ''+String(msg)+'' + (rid? ' (Request-ID: '+rid+')' : '');
+ surface.prepend(banner);
+ setTimeout(function(){ banner.remove(); }, 8000);
+ }
+ } catch(_){ }
+ });
+ document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
+
+ // Keyboard shortcuts
+ const keymap: Record void> = {
+ ' ': function(){ const el = document.querySelector('[data-action="continue"], .btn-continue') as HTMLElement; if (el) el.click(); },
+ 'r': function(){ const el = document.querySelector('[data-action="rerun"], .btn-rerun') as HTMLElement; if (el) el.click(); },
+ 'b': function(){ const el = document.querySelector('[data-action="back"], .btn-back') as HTMLElement; if (el) el.click(); },
+ 'l': function(){ const el = document.querySelector('[data-action="toggle-logs"], .btn-logs') as HTMLElement; if (el) el.click(); },
+ };
+ document.addEventListener('keydown', function(e){
+ const target = e.target as HTMLElement;
+ if (target && (/input|textarea|select/i).test(target.tagName)) return; // don't hijack inputs
+ const k = e.key.toLowerCase();
+ // If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
+ try {
+ const active = document.activeElement as HTMLElement;
+ if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
+ return;
+ }
+ } catch(_) { /* noop */ }
+ if (keymap[k]){ e.preventDefault(); keymap[k](); }
+ });
+
+ // Focus ring visibility for keyboard nav
+ function addFocusVisible(){
+ let hadKeyboardEvent = false;
+ function onKeyDown(){ hadKeyboardEvent = true; }
+ function onPointer(){ hadKeyboardEvent = false; }
+ function onFocus(e: FocusEvent){ if (hadKeyboardEvent) (e.target as HTMLElement).classList.add('focus-visible'); }
+ function onBlur(e: FocusEvent){ (e.target as HTMLElement).classList.remove('focus-visible'); }
+ window.addEventListener('keydown', onKeyDown, true);
+ window.addEventListener('mousedown', onPointer, true);
+ window.addEventListener('pointerdown', onPointer, true);
+ window.addEventListener('touchstart', onPointer, true);
+ document.addEventListener('focusin', onFocus);
+ document.addEventListener('focusout', onBlur);
+ }
+ addFocusVisible();
+
+ // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable
+ let SKELETON_DELAY_DEFAULT = 400;
+ let skeletonTimers = new WeakMap();
+ function gatherSkeletons(root){
+ if (!root){ return []; }
+ let list = [];
+ let scope = (root.nodeType === 9) ? root.documentElement : root;
+ if (scope && scope.matches && scope.hasAttribute('data-skeleton')){
+ list.push(scope);
+ }
+ if (scope && scope.querySelectorAll){
+ scope.querySelectorAll('[data-skeleton]').forEach(function(el){
+ if (list.indexOf(el) === -1){ list.push(el); }
+ });
+ }
+ return list;
+ }
+ function scheduleSkeleton(el){
+ let delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10);
+ let delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr);
+ clearSkeleton(el, false);
+ const timer = setTimeout(function(){
+ el.classList.add('is-loading');
+ el.setAttribute('aria-busy', 'true');
+ skeletonTimers.set(el, null);
+ }, delay);
+ skeletonTimers.set(el, timer);
+ }
+ function clearSkeleton(el: HTMLElement, removeBusy?: boolean): void {
+ let timer = skeletonTimers.get(el);
+ if (typeof timer === 'number'){
+ clearTimeout(timer);
+ }
+ skeletonTimers.delete(el);
+ el.classList.remove('is-loading');
+ if (removeBusy !== false){ el.removeAttribute('aria-busy'); }
+ }
+ function showSkeletons(context?: HTMLElement | Document): void {
+ gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); });
+ }
+ function hideSkeletons(context?: HTMLElement | Document): void {
+ gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el, true); });
+ }
+ window.skeletons = { show: showSkeletons, hide: hideSkeletons };
+
+ document.addEventListener('htmx:beforeRequest', function(e){
+ const detail = e.detail as any;
+ const target = detail.target || detail.elt || e.target;
+ showSkeletons(target);
+ });
+ document.addEventListener('htmx:afterSwap', function(e){
+ const detail = e.detail as any;
+ const target = detail.target || detail.elt || e.target;
+ hideSkeletons(target);
+ });
+ document.addEventListener('htmx:afterRequest', function(e){
+ const detail = e.detail as any;
+ const target = detail.target || detail.elt || e.target;
+ hideSkeletons(target);
+ });
+
+ // Commander catalog image lazy loader
+ (function(){
+ let PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
+ let observer = null;
+ let supportsIO = 'IntersectionObserver' in window;
+
+ function ensureObserver(){
+ if (observer || !supportsIO) return observer;
+ observer = new IntersectionObserver(function(entries){
+ entries.forEach(function(entry){
+ if (entry.isIntersecting || entry.intersectionRatio > 0){
+ let img = entry.target;
+ load(img);
+ if (observer) observer.unobserve(img);
+ }
+ });
+ }, { rootMargin: '160px 0px', threshold: 0.05 });
+ return observer;
+ }
+
+ function load(img){
+ if (!img || img.__lazyLoaded) return;
+ let src = img.getAttribute('data-lazy-src');
+ if (src){ img.setAttribute('src', src); }
+ let srcset = img.getAttribute('data-lazy-srcset');
+ if (srcset){ img.setAttribute('srcset', srcset); }
+ let sizes = img.getAttribute('data-lazy-sizes');
+ if (sizes){ img.setAttribute('sizes', sizes); }
+ img.classList.remove('is-placeholder');
+ img.removeAttribute('data-lazy-image');
+ img.removeAttribute('data-lazy-src');
+ img.removeAttribute('data-lazy-srcset');
+ img.removeAttribute('data-lazy-sizes');
+ img.__lazyLoaded = true;
+ }
+
+ function prime(img){
+ if (!img || img.__lazyPrimed) return;
+ let desired = img.getAttribute('data-lazy-src');
+ if (!desired) return;
+ img.__lazyPrimed = true;
+ let placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL;
+ img.setAttribute('loading', 'lazy');
+ img.setAttribute('decoding', 'async');
+ img.classList.add('is-placeholder');
+ img.removeAttribute('srcset');
+ img.removeAttribute('sizes');
+ img.setAttribute('src', placeholder);
+ if (supportsIO){
+ ensureObserver().observe(img);
+ } else {
+ const loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); };
+ loader(function(){ load(img); });
+ }
+ }
+
+ function collect(scope){
+ if (!scope) scope = document;
+ if (scope === document){
+ return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]'));
+ }
+ if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){
+ return [scope];
+ }
+ if (scope.querySelectorAll){
+ return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]'));
+ }
+ return [];
+ }
+
+ function process(scope){
+ collect(scope).forEach(function(img){
+ if (img.__lazyLoaded) return;
+ prime(img);
+ });
+ }
+
+ if (document.readyState === 'loading'){
+ document.addEventListener('DOMContentLoaded', function(){ process(document); });
+ } else {
+ process(document);
+ }
+
+ document.addEventListener('htmx:afterSwap', function(evt){
+ let target = evt && evt.detail ? evt.detail.target : null;
+ process(target || document);
+ });
+ })();
+
+ const htmxCache = (function(){
+ let store = new Map();
+ function ttlFor(elt){
+ let raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10);
+ if (isNaN(raw) || raw <= 0){ return 30000; }
+ return Math.max(1000, raw);
+ }
+ function buildKey(detail, elt){
+ if (!detail) detail = {};
+ if (elt && elt.getAttribute){
+ let explicit = elt.getAttribute('data-hx-cache-key');
+ if (explicit && explicit.trim()){ return explicit.trim(); }
+ }
+ let verb = (detail.verb || 'GET').toUpperCase();
+ let path = detail.path || '';
+ let params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : '';
+ return verb + ' ' + path + ' ' + params;
+ }
+ function set(key, html, ttl, meta){
+ if (!key || typeof html !== 'string') return;
+ store.set(key, {
+ key: key,
+ html: html,
+ expires: Date.now() + (ttl || 30000),
+ meta: meta || {},
+ });
+ }
+ function get(key){
+ if (!key) return null;
+ let entry = store.get(key);
+ if (!entry) return null;
+ if (entry.expires && entry.expires <= Date.now()){
+ store.delete(key);
+ return null;
+ }
+ return entry;
+ }
+ function applyCached(elt, detail, entry){
+ if (!entry) return;
+ let target = detail && detail.target ? detail.target : elt;
+ if (!target) return;
+ dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ let swapSpec = '';
+ try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ }
+ swapSpec = (swapSpec || 'innerHTML').toLowerCase();
+ if (swapSpec.indexOf('outer') === 0){
+ if (target.outerHTML !== undefined){
+ target.outerHTML = entry.html;
+ }
+ } else if (target.innerHTML !== undefined){
+ target.innerHTML = entry.html;
+ }
+ if (window.htmx && typeof window.htmx.process === 'function'){
+ window.htmx.process(target);
+ }
+ dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ }
+ function prefetch(url, opts){
+ if (!url) return;
+ opts = opts || {};
+ let key = opts.key || ('GET ' + url);
+ if (get(key)) return;
+ try {
+ fetch(url, {
+ headers: { 'HX-Request': 'true', 'Accept': 'text/html' },
+ cache: 'no-store',
+ }).then(function(resp){
+ if (!resp.ok) throw new Error('prefetch failed');
+ return resp.text();
+ }).then(function(html){
+ set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true });
+ telemetry.send('htmx.cache.prefetch', { key: key, url: url });
+ }).catch(function(){ /* noop */ });
+ } catch(_){ }
+ }
+ return {
+ set: set,
+ get: get,
+ apply: applyCached,
+ buildKey: buildKey,
+ ttlFor: ttlFor,
+ prefetch: prefetch,
+ };
+ })();
+ window.htmxCache = htmxCache;
+
+ document.addEventListener('htmx:configRequest', function(e: any){
+ const detail = e && e.detail ? e.detail : {} as any;
+ const elt = detail.elt as HTMLElement;
+ if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return;
+ const verb = (detail.verb || 'GET').toUpperCase();
+ if (verb !== 'GET') return;
+ const key = htmxCache.buildKey(detail, elt);
+ elt.__hxCacheKey = key;
+ elt.__hxCacheTTL = htmxCache.ttlFor(elt);
+ detail.headers = detail.headers || {};
+ try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ }
+ });
+
+ document.addEventListener('htmx:beforeRequest', function(e: any){
+ const detail = e && e.detail ? e.detail : {} as any;
+ const elt = detail.elt as HTMLElement;
+ if (!elt || !elt.__hxCacheKey) return;
+ const entry = htmxCache.get(elt.__hxCacheKey);
+ if (entry){
+ telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' });
+ e.preventDefault();
+ htmxCache.apply(elt, detail, entry);
+ } else {
+ telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' });
+ }
+ });
+
+ document.addEventListener('htmx:afterSwap', function(e: any){
+ const detail = e && e.detail ? e.detail : {} as any;
+ const elt = detail.elt as HTMLElement;
+ if (!elt || !elt.__hxCacheKey) return;
+ try {
+ const xhr = detail.xhr;
+ const status = xhr && xhr.status ? xhr.status : 0;
+ if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){
+ const ttl = elt.__hxCacheTTL || 30000;
+ htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' });
+ telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl });
+ }
+ } catch(_){ }
+ elt.__hxCacheKey = undefined;
+ elt.__hxCacheTTL = undefined;
+ });
+
+ (function(){
+ function handlePrefetch(evt: Event){
+ try {
+ const el = (evt.target as HTMLElement)?.closest ? (evt.target as HTMLElement).closest('[data-hx-prefetch]') : null;
+ if (!el || el.__hxPrefetched) return;
+ let url = el.getAttribute('data-hx-prefetch');
+ if (!url) return;
+ el.__hxPrefetched = true;
+ let key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url);
+ let ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10);
+ let ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr);
+ htmxCache.prefetch(url, { key: key, ttl: ttl });
+ } catch(_){ }
+ }
+ document.addEventListener('pointerenter', handlePrefetch, true);
+ document.addEventListener('focusin', handlePrefetch, true);
+ })();
+
+ // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce)
+ let hxDebounceGroups = new Map();
+ function dispatchHtmx(el, evtName, detail){
+ if (!el) return;
+ if (window.htmx && typeof window.htmx.trigger === 'function'){
+ window.htmx.trigger(el, evtName, detail);
+ } else {
+ try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ }
+ }
+ }
+ function bindHtmxDebounce(el){
+ if (!el || el.__hxDebounceBound) return;
+ el.__hxDebounceBound = true;
+ let delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10);
+ let delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw);
+ let eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input';
+ let events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean);
+ if (!events.length){ events = ['input']; }
+ let trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput';
+ let group = el.getAttribute('data-hx-debounce-group') || '';
+ let flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase();
+ let flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true');
+ function clearTimer(){
+ if (el.__hxDebounceTimer){
+ clearTimeout(el.__hxDebounceTimer);
+ el.__hxDebounceTimer = null;
+ }
+ }
+ function schedule(){
+ clearTimer();
+ if (group){
+ let prev = hxDebounceGroups.get(group);
+ if (prev && prev !== el && prev.__hxDebounceTimer){
+ clearTimeout(prev.__hxDebounceTimer);
+ prev.__hxDebounceTimer = null;
+ }
+ hxDebounceGroups.set(group, el);
+ }
+ el.__hxDebounceTimer = setTimeout(function(){
+ el.__hxDebounceTimer = null;
+ dispatchHtmx(el, trigger, {});
+ }, delay);
+ }
+ events.forEach(function(evt){
+ el.addEventListener(evt, schedule, { passive: true });
+ });
+ if (flushOnBlur){
+ el.addEventListener('blur', function(){
+ if (el.__hxDebounceTimer){
+ clearTimer();
+ dispatchHtmx(el, trigger, {});
+ }
+ });
+ }
+ el.addEventListener('htmx:beforeRequest', clearTimer);
+ }
+ function initHtmxDebounce(root){
+ let scope = root || document;
+ if (scope === document){ scope = document.body || document; }
+ if (!scope) return;
+ let seen = new Set();
+ function collect(candidate){
+ if (!candidate || seen.has(candidate)) return;
+ seen.add(candidate);
+ bindHtmxDebounce(candidate);
+ }
+ if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){
+ collect(scope);
+ }
+ if (scope.querySelectorAll){
+ scope.querySelectorAll('[data-hx-debounce]').forEach(collect);
+ }
+ }
+ window.initHtmxDebounce = () => initHtmxDebounce(document.body);
+
+ // Example: persist "show skipped" toggle if present
+ document.addEventListener('change', function(e){
+ const el = e.target as HTMLInputElement;
+ if (el && el.matches('[data-pref]')){
+ let key = el.getAttribute('data-pref');
+ let val = (el.type === 'checkbox') ? !!el.checked : el.value;
+ state.set(key, val);
+ state.inHash((function(o){ o[key] = val; return o; })({}));
+ }
+ });
+ // On load, initialize any data-pref elements
+ document.addEventListener('DOMContentLoaded', function(){
+ document.querySelectorAll('[data-pref]').forEach(function(el){
+ let key = el.getAttribute('data-pref');
+ let saved = state.get(key, undefined);
+ if (typeof saved !== 'undefined'){
+ if ((el as HTMLInputElement).type === 'checkbox') (el as HTMLInputElement).checked = !!saved; else (el as HTMLInputElement).value = saved;
+ }
+ });
+ hydrateProgress(document);
+ syncShowSkipped(document);
+ initCardFilters(document);
+ initVirtualization(document);
+ initHtmxDebounce(document);
+ initMustHaveControls(document);
+ });
+
+ // Hydrate progress bars with width based on data-pct
+ function hydrateProgress(root){
+ (root || document).querySelectorAll('.progress[data-pct]')
+ .forEach(function(p){
+ let pct = parseInt(p.getAttribute('data-pct') || '0', 10);
+ if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
+ let bar = p.querySelector('.bar'); if (!bar) return;
+ // Animate width for a bit of delight
+ requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
+ });
+ }
+ // Keep hidden inputs for show_skipped in sync with the sticky checkbox
+ function syncShowSkipped(root){
+ let cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
+ if (!cb) return;
+ let val = cb.checked ? '1' : '0';
+ (root || document).querySelectorAll('section form').forEach(function(f){
+ let h = f.querySelector('input[name="show_skipped"]');
+ if (h) h.value = val;
+ });
+ }
+ document.addEventListener('htmx:afterSwap', function(e){
+ hydrateProgress(e.target as HTMLElement);
+ syncShowSkipped(e.target as HTMLElement);
+ initCardFilters(e.target as HTMLElement);
+ initVirtualization(e.target as HTMLElement);
+ initHtmxDebounce(e.target as HTMLElement);
+ initMustHaveControls(e.target as HTMLElement);
+ });
+
+ // Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
+ function scrollCardIntoView(name){
+ if (!name) return;
+ try{
+ let section = document.querySelector('section');
+ let grid = section && section.querySelector('.card-grid');
+ if (!grid) return;
+ // If virtualized, force a render around the approximate match by searching stored children
+ let target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
+ if (!target) {
+ // Trigger a render update and try again
+ grid.dispatchEvent(new Event('scroll')); // noop but can refresh
+ target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
+ }
+ if (target) {
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ (target as HTMLElement).focus && (target as HTMLElement).focus();
+ }
+ }catch(_){}
+ }
+ window.scrollCardIntoView = scrollCardIntoView;
+
+ // --- Card grid filters, reasons, and collapsible groups ---
+ function initCardFilters(root){
+ let section = (root || document).querySelector('section');
+ if (!section) return;
+ let toolbar = section.querySelector('.cards-toolbar');
+ if (!toolbar) return; // nothing to do
+ let q = toolbar.querySelector('input[name="filter_query"]');
+ let ownedSel = toolbar.querySelector('select[name="filter_owned"]');
+ let showReasons = toolbar.querySelector('input[name="show_reasons"]');
+ let collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
+ let resultsEl = toolbar.querySelector('[data-results]');
+ let emptyEl = section.querySelector('[data-empty]');
+ let sortSel = toolbar.querySelector('select[name="filter_sort"]');
+ let chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
+ let chipNot = toolbar.querySelector('[data-chip-owned="not"]');
+ let chipAll = toolbar.querySelector('[data-chip-owned="all"]');
+ let chipClear = toolbar.querySelector('[data-chip-clear]');
+
+ function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
+ // Read URL hash on first init to hydrate controls
+ try {
+ let params = window.__mtgState.readHash();
+ if (params){
+ let hv = params.get('q'); if (q && hv !== null) q.value = hv;
+ hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
+ hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
+ hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
+ hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
+ }
+ } catch(_){}
+ function apply(){
+ let query = (getVal(q)+ '').toLowerCase().trim();
+ let ownedMode = (getVal(ownedSel) || 'all');
+ let showR = !!getVal(showReasons);
+ let collapse = !!getVal(collapseGroups);
+ let sortMode = (getVal(sortSel) || 'az');
+ // Toggle reasons visibility via section class
+ section.classList.toggle('hide-reasons', !showR);
+ // Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
+ section.querySelectorAll('.group').forEach(function(wrapper){
+ let grid = wrapper.querySelector('.group-grid'); if (!grid) return;
+ let key = wrapper.getAttribute('data-group-key');
+ if (collapse){
+ grid.setAttribute('data-collapsed','1');
+ } else {
+ // restore stored
+ if (key){
+ let stored = state.get('cards:group:'+key, null);
+ if (stored === true){ grid.setAttribute('data-collapsed','1'); }
+ else { grid.removeAttribute('data-collapsed'); }
+ } else {
+ grid.removeAttribute('data-collapsed');
+ }
+ }
+ });
+ // Filter tiles
+ let tiles = section.querySelectorAll('.card-grid .card-tile');
+ let visible = 0;
+ tiles.forEach(function(tile){
+ let name = (tile.getAttribute('data-card-name')||'').toLowerCase();
+ let role = (tile.getAttribute('data-role')||'').toLowerCase();
+ let tags = (tile.getAttribute('data-tags')||'').toLowerCase();
+ let tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase();
+ let owned = tile.getAttribute('data-owned') === '1';
+ let text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug;
+ let qOk = !query || text.indexOf(query) !== -1;
+ let oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
+ let show = qOk && oOk;
+ tile.style.display = show ? '' : 'none';
+ if (show) visible++;
+ });
+ // Sort within each grid
+ function keyFor(tile){
+ let name = (tile.getAttribute('data-card-name')||'');
+ let owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
+ let gc = tile.classList.contains('game-changer') ? 1 : 0;
+ return { name: name.toLowerCase(), owned: owned, gc: gc };
+ }
+ section.querySelectorAll('.card-grid').forEach(function(grid){
+ const arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
+ arr.sort(function(a,b){
+ let ka = keyFor(a), kb = keyFor(b);
+ if (sortMode === 'owned'){
+ if (kb.owned !== ka.owned) return kb.owned - ka.owned;
+ if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
+ return ka.name.localeCompare(kb.name);
+ } else if (sortMode === 'gc'){
+ if (kb.gc !== ka.gc) return kb.gc - ka.gc;
+ if (kb.owned !== ka.owned) return kb.owned - ka.owned;
+ return ka.name.localeCompare(kb.name);
+ }
+ // default A–Z
+ return ka.name.localeCompare(kb.name);
+ });
+ arr.forEach(function(el){ grid.appendChild(el); });
+ });
+ // Update group counts based on visible tiles within each group
+ section.querySelectorAll('.group').forEach(function(wrapper){
+ let grid = wrapper.querySelector('.group-grid');
+ let count = 0;
+ if (grid){
+ grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
+ }
+ let cEl = wrapper.querySelector('[data-count]');
+ if (cEl) cEl.textContent = count;
+ });
+ if (resultsEl) resultsEl.textContent = String(visible);
+ if (emptyEl) emptyEl.hidden = (visible !== 0);
+ // Persist prefs
+ if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
+ if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
+ if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
+ if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
+ if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
+ // Update URL hash for shareability
+ try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
+ }
+ // Wire events
+ if (q) q.addEventListener('input', apply);
+ if (ownedSel) ownedSel.addEventListener('change', apply);
+ if (showReasons) showReasons.addEventListener('change', apply);
+ if (collapseGroups) collapseGroups.addEventListener('change', apply);
+ if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
+ if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
+ if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
+ if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
+ // Individual group toggles
+ section.querySelectorAll('.group-header .toggle').forEach(function(btn){
+ btn.addEventListener('click', function(){
+ let wrapper = btn.closest('.group');
+ let grid = wrapper && wrapper.querySelector('.group-grid');
+ if (!grid) return;
+ let key = wrapper.getAttribute('data-group-key');
+ let willCollapse = !grid.getAttribute('data-collapsed');
+ if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
+ if (key){ state.set('cards:group:'+key, !!willCollapse); }
+ // ARIA
+ btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
+ });
+ });
+ // Per-card reason toggle: delegate clicks on .btn-why
+ section.addEventListener('click', function(e){
+ let t = e.target;
+ if (!t || !t.classList || !t.classList.contains('btn-why')) return;
+ e.preventDefault();
+ let tile = t.closest('.card-tile');
+ if (!tile) return;
+ let globalHidden = section.classList.contains('hide-reasons');
+ if (globalHidden){
+ // Force-show overrides global hidden
+ let on = tile.classList.toggle('force-show');
+ if (on) tile.classList.remove('force-hide');
+ t.textContent = on ? 'Hide why' : 'Why?';
+ } else {
+ // Hide this tile only
+ let off = tile.classList.toggle('force-hide');
+ if (off) tile.classList.remove('force-show');
+ t.textContent = off ? 'Show why' : 'Hide why';
+ }
+ });
+ // Initial apply on hydrate
+ apply();
+
+ // Keyboard helpers: '/' focuses query, Esc clears
+ function onKey(e){
+ // avoid when typing in inputs
+ if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return;
+ if (e.key === '/'){
+ if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
+ } else if (e.key === 'Escape'){
+ if (q && q.value){ q.value=''; apply(); }
+ }
+ }
+ document.addEventListener('keydown', onKey);
+ }
+
+ // --- Lightweight virtualization (feature-flagged via data-virtualize) ---
+ function initVirtualization(root){
+ try{
+ let body = document.body || document.documentElement;
+ const DIAG = !!(body && body.getAttribute('data-diag') === '1');
+ const GLOBAL = (function(){
+ if (!DIAG) return null;
+ if (window.__virtGlobal) return window.__virtGlobal;
+ let store = { grids: [], summaryEl: null };
+ function ensure(){
+ if (!store.summaryEl){
+ let el = document.createElement('div');
+ el.id = 'virt-global-diag';
+ el.style.position = 'fixed';
+ el.style.right = '8px';
+ el.style.bottom = '8px';
+ el.style.background = 'rgba(17,24,39,.85)';
+ el.style.border = '1px solid var(--border)';
+ el.style.padding = '.25rem .5rem';
+ el.style.borderRadius = '6px';
+ el.style.fontSize = '12px';
+ el.style.color = '#cbd5e1';
+ el.style.zIndex = '50';
+ el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
+ el.style.cursor = 'default';
+ el.style.display = 'none';
+ document.body.appendChild(el);
+ store.summaryEl = el;
+ }
+ return store.summaryEl;
+ }
+ function update(){
+ let el = ensure(); if (!el) return;
+ let g = store.grids;
+ let total = 0, visible = 0, lastMs = 0;
+ for (let i=0;i -1 ? 110 : 240);
+ let minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow;
+ let rowH = minRowH;
+ let explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null;
+ let perRow = explicitCols || 1;
+
+ let diagBox = null; let lastRenderAt = 0; let lastRenderMs = 0;
+ let renderCount = 0; let measureCount = 0; let swapCount = 0;
+ let gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
+ let globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
+
+ function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
+ function ensureDiag(){
+ if (!DIAG) return null;
+ if (diagBox) return diagBox;
+ diagBox = document.createElement('div');
+ diagBox.className = 'virt-diag';
+ diagBox.style.position = 'sticky';
+ diagBox.style.top = '0';
+ diagBox.style.zIndex = '5';
+ diagBox.style.background = 'rgba(17,24,39,.85)';
+ diagBox.style.border = '1px solid var(--border)';
+ diagBox.style.padding = '.25rem .5rem';
+ diagBox.style.borderRadius = '6px';
+ diagBox.style.fontSize = '12px';
+ diagBox.style.margin = '0 0 .35rem 0';
+ diagBox.style.color = '#cbd5e1';
+ diagBox.style.display = 'none';
+ let controls = document.createElement('div');
+ controls.style.display = 'flex';
+ controls.style.gap = '.35rem';
+ controls.style.alignItems = 'center';
+ controls.style.marginBottom = '.25rem';
+ let title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
+ let btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
+ btnCopy.addEventListener('click', function(){
+ try{
+ let payload = {
+ id: gridId,
+ rowH: rowH,
+ perRow: perRow,
+ start: start,
+ end: end,
+ total: total,
+ renderCount: renderCount,
+ measureCount: measureCount,
+ swapCount: swapCount,
+ lastRenderMs: lastRenderMs,
+ lastRenderAt: lastRenderAt,
+ };
+ navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
+ btnCopy.textContent = 'Copied';
+ setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200);
+ }catch(_){ }
+ });
+ let btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
+ btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
+ controls.appendChild(title);
+ controls.appendChild(btnCopy);
+ controls.appendChild(btnHide);
+ diagBox.appendChild(controls);
+ let text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
+ let host = (container.id === 'owned-box') ? container : container.parentElement || container;
+ host.insertBefore(diagBox, host.firstChild);
+ return diagBox;
+ }
+
+ function measure(){
+ try {
+ measureCount++;
+ let probe = store.firstElementChild || all[0];
+ if (probe){
+ let fake = probe.cloneNode(true);
+ fake.style.position = 'absolute';
+ fake.style.visibility = 'hidden';
+ fake.style.pointerEvents = 'none';
+ (ownedGrid || container).appendChild(fake);
+ let rect = fake.getBoundingClientRect();
+ rowH = Math.max(minRowH, Math.ceil(rect.height) + 16);
+ (ownedGrid || container).removeChild(fake);
+ }
+ let style = window.getComputedStyle(ownedGrid || container);
+ let cols = style.getPropertyValue('grid-template-columns');
+ try {
+ let displayMode = style.getPropertyValue('display');
+ if (displayMode && displayMode.trim()){
+ wrapper.style.display = displayMode;
+ } else if (!wrapper.style.display){
+ wrapper.style.display = 'grid';
+ }
+ if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
+ let gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
+ if (gap && gap.trim()) wrapper.style.gap = gap;
+ let ji = style.getPropertyValue('justify-items');
+ if (ji && ji.trim()) wrapper.style.justifyItems = ji;
+ let ai = style.getPropertyValue('align-items');
+ if (ai && ai.trim()) wrapper.style.alignItems = ai;
+ } catch(_){ }
+ const derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){
+ return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);
+ }).length : 0);
+ if (explicitCols){
+ perRow = explicitCols;
+ } else if (derivedCols){
+ perRow = Math.max(1, derivedCols);
+ } else {
+ perRow = Math.max(1, perRow);
+ }
+ } catch(_){ }
+ }
+
+ measure();
+ let total = all.length;
+ let start = 0, end = 0;
+
+ function render(){
+ let t0 = DIAG ? performance.now() : 0;
+ let scroller = container;
+ let vh, scrollTop, top;
+
+ if (useWindowScroll) {
+ // Window-scroll mode: measure relative to viewport
+ vh = window.innerHeight;
+ let rect = container.getBoundingClientRect();
+ top = Math.max(0, -rect.top);
+ scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
+ } else {
+ // Container-scroll mode: measure relative to container
+ vh = scroller.clientHeight || window.innerHeight;
+ scrollTop = scroller.scrollTop;
+ top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
+ }
+
+ let rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2;
+ let rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1);
+ let rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow)));
+ let newStart = rowStart * Math.max(1, perRow);
+ let newEnd = Math.min(total, rowEnd * Math.max(1, perRow));
+ if (newStart === start && newEnd === end) return;
+ start = newStart;
+ end = newEnd;
+ let beforeRows = Math.floor(start / Math.max(1, perRow));
+ let afterRows = Math.ceil((total - end) / Math.max(1, perRow));
+ padTop.style.height = (beforeRows * rowH) + 'px';
+ padBottom.style.height = (afterRows * rowH) + 'px';
+ wrapper.innerHTML = '';
+ for (let i = start; i < end; i++){
+ let node = all[i];
+ if (node) wrapper.appendChild(node);
+ }
+ if (DIAG){
+ let box = ensureDiag();
+ if (box){
+ let dt = performance.now() - t0;
+ lastRenderMs = dt;
+ renderCount++;
+ lastRenderAt = Date.now();
+ let vis = end - start;
+ let rowsTotal = Math.ceil(total / Math.max(1, perRow));
+ let textEl = box.querySelector('.virt-diag-text');
+ let msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
+ textEl.textContent = msg;
+ let bad = (dt > 33) || (vis > 300);
+ let warn = (!bad) && ((dt > 16) || (vis > 200));
+ box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
+ box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
+ if (globalReg && globalReg.set){
+ globalReg.set({ total: total, start: start, end: end, lastMs: dt });
+ }
+ }
+ }
+ }
+
+ function onScroll(){ render(); }
+ function onResize(){ measure(); render(); }
+
+ // Support both container-scroll (default) and window-scroll modes
+ let scrollMode = overflowAttr || container.style.overflow || 'auto';
+ let useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window');
+
+ if (useWindowScroll) {
+ // Window-scroll mode: listen to window scroll events
+ window.addEventListener('scroll', onScroll, { passive: true });
+ } else {
+ // Container-scroll mode: listen to container scroll events
+ container.addEventListener('scroll', onScroll, { passive: true });
+ }
+ window.addEventListener('resize', onResize);
+
+ render();
+
+ // Track cleanup for disconnected containers
+ grid.__virtCleanup = function(){
+ try {
+ if (useWindowScroll) {
+ window.removeEventListener('scroll', onScroll);
+ } else {
+ container.removeEventListener('scroll', onScroll);
+ }
+ window.removeEventListener('resize', onResize);
+ } catch(_){}
+ };
+
+ document.addEventListener('htmx:afterSwap', function(ev){
+ if (!container.isConnected) return;
+ if (!container.contains(ev.target)) return;
+ swapCount++;
+ let merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children));
+ const known = new Map();
+ all.forEach(function(node, idx){
+ let index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx;
+ known.set(node, index);
+ });
+ let nextIndex = known.size;
+ merged.forEach(function(node){
+ if (!known.has(node)){
+ node.__virtIndex = nextIndex;
+ known.set(node, nextIndex);
+ nextIndex++;
+ }
+ });
+ merged.sort(function(a, b){
+ let ia = known.get(a);
+ const ib = known.get(b);
+ return (ia - ib);
+ });
+ merged.forEach(function(node, idx){ node.__virtIndex = idx; });
+ all = merged;
+ total = all.length;
+ measure();
+ render();
+ });
+
+ if (DIAG && !window.__virtHotkeyBound){
+ window.__virtHotkeyBound = true;
+ document.addEventListener('keydown', function(e){
+ try{
+ if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return;
+ if (e.key && e.key.toLowerCase() === 'v'){
+ e.preventDefault();
+ let shown = null;
+ document.querySelectorAll('.virt-diag').forEach(function(b){
+ if (shown === null) shown = ((b as HTMLElement).style.display === 'none');
+ (b as HTMLElement).style.display = shown ? '' : 'none';
+ });
+ if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
+ }
+ }catch(_){ }
+ });
+ }
+ });
+ }catch(_){ }
+ }
+
+ function setTileState(tile, type, active){
+ if (!tile) return;
+ let attr = 'data-must-' + type;
+ tile.setAttribute(attr, active ? '1' : '0');
+ tile.classList.toggle('must-' + type, !!active);
+ let selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude');
+ try {
+ let btn = tile.querySelector(selector);
+ if (btn){
+ btn.setAttribute('data-active', active ? '1' : '0');
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ btn.classList.toggle('is-active', !!active);
+ }
+ } catch(_){ }
+ }
+
+ function restoreMustHaveState(tile, state){
+ if (!tile || !state) return;
+ setTileState(tile, 'include', state.include ? 1 : 0);
+ setTileState(tile, 'exclude', state.exclude ? 1 : 0);
+ }
+
+ function applyLocalMustHave(tile, type, enabled){
+ if (!tile) return;
+ if (type === 'include'){
+ setTileState(tile, 'include', enabled ? 1 : 0);
+ if (enabled){ setTileState(tile, 'exclude', 0); }
+ } else if (type === 'exclude'){
+ setTileState(tile, 'exclude', enabled ? 1 : 0);
+ if (enabled){ setTileState(tile, 'include', 0); }
+ }
+ }
+
+ function sendMustHaveRequest(tile, type, enabled, cardName, prevState){
+ if (!window.htmx){
+ restoreMustHaveState(tile, prevState);
+ tile.setAttribute('data-must-pending', '0');
+ toast('Offline: cannot update preference', 'error', { duration: 4000 });
+ return;
+ }
+ let summaryTarget = document.getElementById('include-exclude-summary');
+ let ajaxOptions = {
+ source: tile,
+ target: summaryTarget || tile,
+ swap: summaryTarget ? 'outerHTML' : 'none',
+ values: {
+ card_name: cardName,
+ list_type: type,
+ enabled: enabled ? '1' : '0',
+ },
+ };
+ let xhr;
+ try {
+ xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions);
+ } catch(_){
+ restoreMustHaveState(tile, prevState);
+ tile.setAttribute('data-must-pending', '0');
+ toast('Unable to submit preference update', 'error', { duration: 4500 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' });
+ return;
+ }
+ if (!xhr || !xhr.addEventListener){
+ tile.setAttribute('data-must-pending', '0');
+ return;
+ }
+ xhr.addEventListener('load', function(evt){
+ tile.setAttribute('data-must-pending', '0');
+ let request = evt && evt.currentTarget ? evt.currentTarget : xhr;
+ let status = request.status || 0;
+ if (status >= 400){
+ restoreMustHaveState(tile, prevState);
+ let msg = 'Failed to update preference';
+ try {
+ let data = JSON.parse(request.responseText || '{}');
+ if (data && data.error) msg = data.error;
+ } catch(_){ }
+ toast(msg, 'error', { duration: 5000 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status });
+ return;
+ }
+ let message;
+ if (enabled){
+ message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude';
+ } else {
+ message = (type === 'include') ? 'Removed must include' : 'Removed must exclude';
+ }
+ toast(message + ': ' + cardName, 'success', { duration: 2400 });
+ telemetry.send('must_have.toggle', {
+ card: cardName,
+ list: type,
+ enabled: enabled,
+ requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null,
+ });
+ });
+ xhr.addEventListener('error', function(){
+ tile.setAttribute('data-must-pending', '0');
+ restoreMustHaveState(tile, prevState);
+ toast('Network error updating preference', 'error', { duration: 5000 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' });
+ });
+ }
+
+ function initMustHaveControls(root){
+ let scope = root && root.querySelectorAll ? root : document;
+ if (scope === document && document.body) scope = document.body;
+ if (!scope || !scope.querySelectorAll) return;
+ scope.querySelectorAll('.must-have-btn').forEach(function(btn){
+ if (!btn || btn.__mustHaveBound) return;
+ btn.__mustHaveBound = true;
+ let active = btn.getAttribute('data-active') === '1';
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ btn.addEventListener('click', function(ev){
+ ev.preventDefault();
+ let tile = btn.closest('.card-tile');
+ if (!tile) return;
+ if (tile.getAttribute('data-must-pending') === '1') return;
+ let type = btn.getAttribute('data-toggle');
+ if (!type) return;
+ let prevState = {
+ include: tile.getAttribute('data-must-include') === '1',
+ exclude: tile.getAttribute('data-must-exclude') === '1',
+ };
+ let nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude);
+ let label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || '';
+ tile.setAttribute('data-must-pending', '1');
+ applyLocalMustHave(tile, type, nextEnabled);
+ sendMustHaveRequest(tile, type, nextEnabled, label, prevState);
+ });
+ });
+ }
+
+ // LQIP blur/fade-in for thumbnails marked with data-lqip
+ document.addEventListener('DOMContentLoaded', function(){
+ try{
+ document.querySelectorAll('img[data-lqip]')
+ .forEach(function(img){
+ img.classList.add('lqip');
+ img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
+ });
+ }catch(_){ }
+ });
+
+ // --- Lazy-loading analytics accordions ---
+ function initLazyAccordions(root){
+ try {
+ let scope = root || document;
+ if (!scope || !scope.querySelectorAll) return;
+
+ scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){
+ if (!details || details.__lazyBound) return;
+ details.__lazyBound = true;
+
+ let loaded = false;
+
+ details.addEventListener('toggle', function(){
+ if (!details.open || loaded) return;
+ loaded = true;
+
+ // Mark as loaded to prevent re-initialization
+ let content = details.querySelector('.analytics-content');
+ if (!content) return;
+
+ // Remove placeholder if present
+ let placeholder = content.querySelector('.analytics-placeholder');
+ if (placeholder) {
+ placeholder.remove();
+ }
+
+ // Content is already rendered in the template, just need to initialize any scripts
+ // Re-run virtualization if needed
+ try {
+ initVirtualization(content);
+ } catch(_){}
+
+ // Re-attach chart interactivity if this is mana overview
+ let type = details.getAttribute('data-analytics-type');
+ if (type === 'mana') {
+ try {
+ // Tooltip and highlight logic is already in the template scripts
+ // Just trigger a synthetic event to re-attach if needed
+ let event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } });
+ details.dispatchEvent(event);
+ } catch(_){}
+ }
+
+ // Send telemetry
+ telemetry.send('analytics.accordion_expand', {
+ type: type || 'unknown',
+ accordion: details.id || 'unnamed',
+ });
+ });
+ });
+ } catch(_){}
+ }
+
+ // Initialize on load and after HTMX swaps
+ document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(document.body); });
+ document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
+
+ // =============================================================================
+ // UTILITIES EXTRACTED FROM BASE.HTML INLINE SCRIPTS (Phase 3)
+ // =============================================================================
+
+ /**
+ * Poll setup status endpoint for progress updates
+ * Shows dynamic status message in #banner-status element
+ */
+ function initSetupStatusPoller(): void {
+ let statusEl: HTMLElement | null = null;
+
+ function ensureStatusEl(): HTMLElement | null {
+ if (!statusEl) statusEl = document.getElementById('banner-status');
+ return statusEl;
+ }
+
+ function renderSetupStatus(data: any): void {
+ const el = ensureStatusEl();
+ if (!el) return;
+
+ if (data && data.running) {
+ const msg = data.message || 'Preparing data...';
+ const pct = (typeof data.percent === 'number') ? data.percent : null;
+
+ // Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
+ let suppress = false;
+ if (pct !== null && pct >= 99) suppress = true;
+ const lm = (msg || '').toLowerCase();
+ if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true;
+
+ if (suppress) {
+ if (el.innerHTML) {
+ el.innerHTML = '';
+ el.classList.remove('busy');
+ }
+ return;
+ }
+
+ el.innerHTML = 'Setup/Tagging: ' + msg + ' View progress';
+ el.classList.add('busy');
+ } else if (data && data.phase === 'done') {
+ el.innerHTML = '';
+ el.classList.remove('busy');
+ } else if (data && data.phase === 'error') {
+ el.innerHTML = 'Setup error.';
+ setTimeout(function(){
+ el.innerHTML = '';
+ el.classList.remove('busy');
+ }, 5000);
+ } else {
+ if (!el.innerHTML.trim()) el.innerHTML = '';
+ el.classList.remove('busy');
+ }
+ }
+
+ function pollStatus(): void {
+ try {
+ fetch('/status/setup', { cache: 'no-store' })
+ .then(function(r){ return r.json(); })
+ .then(renderSetupStatus)
+ .catch(function(){ /* noop */ });
+ } catch(_){}
+ }
+
+ // Poll every 10 seconds to reduce server load (only for header indicator)
+ setInterval(pollStatus, 10000);
+ pollStatus(); // Initial poll
+ }
+
+ /**
+ * Highlight active navigation link based on current path
+ * Matches exact or prefix paths, prioritizing longer matches
+ */
+ function initActiveNavHighlighter(): void {
+ try {
+ const path = window.location.pathname || '/';
+ const nav = document.getElementById('primary-nav');
+ if (!nav) return;
+
+ const links = nav.querySelectorAll('a');
+ let best: HTMLAnchorElement | null = null;
+ let bestLen = -1;
+
+ links.forEach(function(a){
+ const href = a.getAttribute('href') || '';
+ if (!href) return;
+ // Exact match or prefix match (ignoring trailing slash)
+ if (path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
+ if (href.length > bestLen){
+ best = a as HTMLAnchorElement;
+ bestLen = href.length;
+ }
+ }
+ });
+
+ if (best) best.classList.add('active');
+ } catch(_){}
+ }
+
+ /**
+ * Initialize theme selector dropdown and persistence
+ * Handles localStorage, URL overrides, and system preference tracking
+ */
+ function initThemeSelector(enableThemes: boolean, defaultTheme: string): void {
+ if (!enableThemes) return;
+
+ try {
+ const sel = document.getElementById('theme-select') as HTMLSelectElement | null;
+ const resetBtn = document.getElementById('theme-reset');
+ const root = document.documentElement;
+ const KEY = 'mtg:theme';
+ const SERVER_DEFAULT = defaultTheme;
+
+ function mapLight(v: string): string {
+ return v === 'light' ? 'light-blend' : v;
+ }
+
+ function resolveSystem(): string {
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return prefersDark ? 'dark' : 'light-blend';
+ }
+
+ function normalizeUiValue(v: string): string {
+ const x = (v || 'system').toLowerCase();
+ if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
+ return x;
+ }
+
+ function apply(val: string): void {
+ let v = (val || 'system').toLowerCase();
+ if (v === 'system') v = resolveSystem();
+ v = mapLight(v);
+ root.setAttribute('data-theme', v);
+ }
+
+ // Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
+ const params = new URLSearchParams(window.location.search || '');
+ const urlTheme = (params.get('theme') || '').toLowerCase();
+ if (urlTheme) {
+ // Persist the UI value, not the mapped CSS token
+ localStorage.setItem(KEY, normalizeUiValue(urlTheme));
+ // Clean the URL so reloads don't keep overriding
+ try {
+ const u = new URL(window.location.href);
+ u.searchParams.delete('theme');
+ window.history.replaceState({}, document.title, u.toString());
+ } catch(_){}
+ }
+
+ // Determine initial selection: URL -> localStorage -> server default -> system
+ const stored = localStorage.getItem(KEY);
+ const initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
+ apply(initial);
+
+ if (sel) {
+ sel.value = normalizeUiValue(initial);
+ sel.addEventListener('change', function(){
+ const v = sel.value || 'system';
+ localStorage.setItem(KEY, v);
+ apply(v);
+ });
+ }
+
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function(){
+ try { localStorage.removeItem(KEY); } catch(_){}
+ const v = SERVER_DEFAULT || 'system';
+ apply(v);
+ if (sel) sel.value = normalizeUiValue(v);
+ });
+ }
+
+ // React to system changes when set to system
+ if (window.matchMedia) {
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ mq.addEventListener && mq.addEventListener('change', function(){
+ const cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
+ if (cur === 'system') apply('system');
+ });
+ }
+ } catch(_){}
+ }
+
+ /**
+ * Apply theme from environment variable when selector is disabled
+ * Resolves 'system' to OS preference
+ */
+ function initThemeEnvOnly(enableThemes: boolean, defaultTheme: string): void {
+ if (enableThemes) return; // Only run when themes are disabled
+
+ try {
+ const root = document.documentElement;
+ const SERVER_DEFAULT = defaultTheme;
+
+ function resolveSystem(): string {
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return prefersDark ? 'dark' : 'light-blend';
+ }
+
+ let v = (SERVER_DEFAULT || 'system').toLowerCase();
+ if (v === 'system') v = resolveSystem();
+ if (v === 'light') v = 'light-blend';
+ root.setAttribute('data-theme', v);
+
+ // Track OS changes when using system
+ if ((SERVER_DEFAULT || 'system').toLowerCase() === 'system' && window.matchMedia) {
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+ mq.addEventListener && mq.addEventListener('change', function(){
+ root.setAttribute('data-theme', resolveSystem());
+ });
+ }
+ } catch(_){}
+ }
+
+ /**
+ * Register PWA service worker and handle updates
+ * Automatically reloads when new version is available
+ */
+ function initServiceWorker(enablePwa: boolean, catalogHash: string): void {
+ if (!enablePwa) return;
+
+ try {
+ if ('serviceWorker' in navigator) {
+ const ver = catalogHash || 'dev';
+ const url = '/static/sw.js?v=' + encodeURIComponent(ver);
+
+ navigator.serviceWorker.register(url).then(function(reg){
+ (window as any).__pwaStatus = { registered: true, scope: reg.scope, version: ver };
+
+ // Listen for updates (new worker installing)
+ if (reg.waiting) {
+ reg.waiting.postMessage({ type: 'SKIP_WAITING' });
+ }
+
+ reg.addEventListener('updatefound', function(){
+ try {
+ const nw = reg.installing;
+ if (!nw) return;
+
+ nw.addEventListener('statechange', function(){
+ if (nw.state === 'installed' && navigator.serviceWorker.controller) {
+ // New version available; reload silently for freshness
+ try {
+ sessionStorage.setItem('mtg:swUpdated', '1');
+ } catch(_){}
+ window.location.reload();
+ }
+ });
+ } catch(_){}
+ });
+ }).catch(function(){
+ (window as any).__pwaStatus = { registered: false };
+ });
+ }
+ } catch(_){}
+ }
+
+ /**
+ * Show toast after page reload
+ * Used when actions replace the whole document
+ */
+ function initToastAfterReload(): void {
+ try {
+ const raw = sessionStorage.getItem('mtg:toastAfterReload');
+ if (raw) {
+ sessionStorage.removeItem('mtg:toastAfterReload');
+ const data = JSON.parse(raw);
+ if (data && data.msg) {
+ window.toast && window.toast(data.msg, data.type || '');
+ }
+ }
+ } catch(_){}
+ }
+
+ // Initialize all utilities on DOMContentLoaded
+ document.addEventListener('DOMContentLoaded', function(){
+ initSetupStatusPoller();
+ initActiveNavHighlighter();
+ initToastAfterReload();
+
+ // Theme and PWA initialization require server-injected values
+ // These will be called from base.html inline scripts that pass the values
+ // window.__initThemeSelector, window.__initThemeEnvOnly, window.__initServiceWorker
+ });
+
+ // Expose functions globally for inline script calls (with server values)
+ (window as any).__initThemeSelector = initThemeSelector;
+ (window as any).__initThemeEnvOnly = initThemeEnvOnly;
+ (window as any).__initServiceWorker = initServiceWorker;
+})();
diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts
new file mode 100644
index 0000000..15f0836
--- /dev/null
+++ b/code/web/static/ts/cardHover.ts
@@ -0,0 +1,798 @@
+/**
+ * Card Hover Panel System
+ *
+ * Unified hover/tap card preview panel with mobile support.
+ * Displays card images with metadata (role, tags, themes, overlaps).
+ *
+ * Features:
+ * - Desktop: Hover to show, follows mouse pointer
+ * - Mobile: Tap to show, centered modal with close button
+ * - Keyboard accessible with focus/escape handling
+ * - Image prefetch LRU cache for performance
+ * - DFC (double-faced card) flip support
+ * - Tag overlap highlighting
+ * - Curated-only and reasons toggles for preview modals
+ *
+ * NOTE: This module exposes functions globally on window for browser compatibility
+ */
+
+interface PointerEventLike {
+ clientX: number;
+ clientY: number;
+}
+
+// Expose globally for browser usage (CommonJS exports don't work in browser without bundler)
+(window as any).__initHoverCardPanel = function initHoverCardPanel(): void {
+ // Global delegated curated-only & reasons controls (works after HTMX swaps and inline render)
+ function findPreviewRoot(el: Element): Element | null {
+ return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content');
+ }
+
+ function applyCuratedFor(root: Element): void {
+ const checkbox = root.querySelector('#curated-only-toggle') as HTMLInputElement | null;
+ const status = root.querySelector('#preview-status') as HTMLElement | null;
+ if (!checkbox) return;
+
+ // Persist
+ try {
+ localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1' : '0');
+ } catch (_) { }
+
+ const curatedOnly = checkbox.checked;
+ let hidden = 0;
+ root.querySelectorAll('.card-sample').forEach((card) => {
+ const role = card.getAttribute('data-role');
+ const isCurated = role === 'example' || role === 'curated_synergy' || role === 'synthetic';
+ if (curatedOnly && !isCurated) {
+ (card as HTMLElement).style.display = 'none';
+ hidden++;
+ } else {
+ (card as HTMLElement).style.display = '';
+ }
+ });
+
+ if (status) status.textContent = curatedOnly ? (`Hid ${hidden} sampled cards`) : '';
+ }
+
+ function applyReasonsFor(root: Element): void {
+ const cb = root.querySelector('#reasons-toggle') as HTMLInputElement | null;
+ if (!cb) return;
+
+ try {
+ localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1' : '0');
+ } catch (_) { }
+
+ const show = cb.checked;
+ root.querySelectorAll('[data-reasons-block]').forEach((el) => {
+ (el as HTMLElement).style.display = show ? '' : 'none';
+ });
+ }
+
+ document.addEventListener('change', (e) => {
+ if (e.target && (e.target as HTMLElement).id === 'curated-only-toggle') {
+ const root = findPreviewRoot(e.target as HTMLElement);
+ if (root) applyCuratedFor(root);
+ }
+ });
+
+ document.addEventListener('change', (e) => {
+ if (e.target && (e.target as HTMLElement).id === 'reasons-toggle') {
+ const root = findPreviewRoot(e.target as HTMLElement);
+ if (root) applyReasonsFor(root);
+ }
+ });
+
+ document.addEventListener('htmx:afterSwap', (ev: any) => {
+ const frag = ev.target;
+ if (frag && frag.querySelector) {
+ if (frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag);
+ if (frag.querySelector('#reasons-toggle')) applyReasonsFor(frag);
+ }
+ });
+
+ document.addEventListener('DOMContentLoaded', () => {
+ document.querySelectorAll('.preview-modal-content').forEach((root) => {
+ // Restore persisted states before applying
+ try {
+ const cVal = localStorage.getItem('mtg:preview.curatedOnly');
+ if (cVal !== null) {
+ const cb = root.querySelector('#curated-only-toggle') as HTMLInputElement | null;
+ if (cb) cb.checked = cVal === '1';
+ }
+ const rVal = localStorage.getItem('mtg:preview.showReasons');
+ if (rVal !== null) {
+ const rb = root.querySelector('#reasons-toggle') as HTMLInputElement | null;
+ if (rb) rb.checked = rVal === '1';
+ }
+ } catch (_) { }
+
+ if (root.querySelector('#curated-only-toggle')) applyCuratedFor(root);
+ if (root.querySelector('#reasons-toggle')) applyReasonsFor(root);
+ });
+ });
+
+ function createPanel(): HTMLElement {
+ const panel = document.createElement('div');
+ panel.id = 'hover-card-panel';
+ panel.setAttribute('role', 'dialog');
+ panel.setAttribute('aria-label', 'Card detail hover panel');
+ panel.setAttribute('aria-hidden', 'true');
+ panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:var(--text);font-size:14px;line-height:1.45;pointer-events:none;';
+ panel.innerHTML = '' +
+ '' +
+ '' +
+ '
' +
+ '
![Card image]()
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ document.body.appendChild(panel);
+ return panel;
+ }
+
+ function ensurePanel(): HTMLElement {
+ let panel = document.getElementById('hover-card-panel');
+ if (panel) return panel;
+ // Auto-create for direct theme pages where fragment-specific markup not injected
+ return createPanel();
+ }
+
+ function setup(): void {
+ const panel = ensurePanel();
+ if (!panel || (panel as any).__hoverInit) return;
+ (panel as any).__hoverInit = true;
+
+ const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement;
+ const nameEl = panel.querySelector('.hcp-name') as HTMLElement;
+ const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement;
+ const metaEl = panel.querySelector('.hcp-meta') as HTMLElement;
+ const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement;
+ const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement;
+ const bodyEl = panel.querySelector('.hcp-body') as HTMLElement;
+ const rightCol = panel.querySelector('.hcp-right') as HTMLElement;
+
+ const coarseQuery = window.matchMedia('(pointer: coarse)');
+
+ function isMobileMode(): boolean {
+ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768;
+ }
+
+ function refreshPosition(): void {
+ if (panel.style.display === 'block') {
+ move((window as any).__lastPointerEvent);
+ }
+ }
+
+ if (coarseQuery) {
+ const handler = () => { refreshPosition(); };
+ if (coarseQuery.addEventListener) {
+ coarseQuery.addEventListener('change', handler);
+ } else if ((coarseQuery as any).addListener) {
+ (coarseQuery as any).addListener(handler);
+ }
+ }
+
+ window.addEventListener('resize', refreshPosition);
+
+ const closeBtn = panel.querySelector('.hcp-close') as HTMLButtonElement;
+ if (closeBtn && !(closeBtn as any).__bound) {
+ (closeBtn as any).__bound = true;
+ closeBtn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ hide();
+ });
+ }
+
+ function positionPanel(evt: PointerEventLike): void {
+ if (isMobileMode()) {
+ panel.classList.add('mobile');
+ panel.style.bottom = 'auto';
+ panel.style.left = '50%';
+ panel.style.top = '50%';
+ panel.style.right = 'auto';
+ panel.style.transform = 'translate(-50%, -50%)';
+ panel.style.pointerEvents = 'auto';
+ } else {
+ panel.classList.remove('mobile');
+ panel.style.pointerEvents = 'none';
+ panel.style.transform = 'none';
+ const pad = 18;
+ let x = evt.clientX + pad, y = evt.clientY + pad;
+ const vw = window.innerWidth, vh = window.innerHeight;
+ const r = panel.getBoundingClientRect();
+ if (x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
+ if (y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
+ if (x < 8) x = 8;
+ if (y < 8) y = 8;
+ panel.style.left = x + 'px';
+ panel.style.top = y + 'px';
+ panel.style.bottom = 'auto';
+ panel.style.right = 'auto';
+ }
+ }
+
+ function move(evt?: PointerEventLike): void {
+ if (panel.style.display === 'none') return;
+ if (!evt) evt = (window as any).__lastPointerEvent;
+ if (!evt && lastCard) {
+ const rect = lastCard.getBoundingClientRect();
+ evt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
+ }
+ if (!evt) evt = { clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 };
+ positionPanel(evt);
+ }
+
+ // Lightweight image prefetch LRU cache (size 12)
+ const imgLRU: string[] = [];
+ function prefetch(src: string): void {
+ if (!src) return;
+ if (imgLRU.indexOf(src) === -1) {
+ imgLRU.push(src);
+ if (imgLRU.length > 12) imgLRU.shift();
+ const im = new Image();
+ im.src = src;
+ }
+ }
+
+ const activationDelay = 120; // ms
+ let hoverTimer: number | null = null;
+
+ function schedule(card: Element, evt: PointerEventLike): void {
+ if (hoverTimer !== null) clearTimeout(hoverTimer);
+ hoverTimer = window.setTimeout(() => { show(card, evt); }, activationDelay);
+ }
+
+ function cancelSchedule(): void {
+ if (hoverTimer !== null) {
+ clearTimeout(hoverTimer);
+ hoverTimer = null;
+ }
+ }
+
+ let lastCard: Element | null = null;
+
+ function show(card: Element, evt?: PointerEventLike): void {
+ if (!card) return;
+
+ // Prefer attributes on container, fallback to child (image) if missing
+ function attr(name: string): string {
+ return card.getAttribute(name) ||
+ (card.querySelector(`[data-${name.slice(5)}]`)?.getAttribute(name)) || '';
+ }
+
+ let simpleSource: Element | null = null;
+ if (card.closest) {
+ simpleSource = card.closest('[data-hover-simple]');
+ }
+
+ const forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
+ const nm = attr('data-card-name') || attr('data-original-name') || 'Card';
+ const rarity = (attr('data-rarity') || '').trim();
+ const mana = (attr('data-mana') || '').trim();
+ const role = (attr('data-role') || '').trim();
+ let reasonsRaw = attr('data-reasons') || '';
+ const tagsRaw = attr('data-tags') || '';
+ const metadataTagsRaw = attr('data-metadata-tags') || '';
+ const roleEl = panel.querySelector('.hcp-role') as HTMLElement;
+ // Check for flip button on card or its parent container (for
elements in commander browser)
+ let hasFlip = !!card.querySelector('.dfc-toggle');
+ if (!hasFlip && card.parentElement) {
+ hasFlip = !!card.parentElement.querySelector('.dfc-toggle');
+ }
+ const tagListEl = panel.querySelector('.hcp-taglist') as HTMLElement;
+ const overlapsEl = panel.querySelector('.hcp-overlaps') as HTMLElement;
+ const overlapsAttr = attr('data-overlaps') || '';
+
+ function displayLabel(text: string): string {
+ if (!text) return '';
+ let label = String(text);
+ label = label.replace(/[\u2022\-_]+/g, ' ');
+ label = label.replace(/\s+/g, ' ').trim();
+ return label;
+ }
+
+ function parseTagList(raw: string): string[] {
+ if (!raw) return [];
+ const trimmed = String(raw).trim();
+ if (!trimmed) return [];
+ let result: string[] = [];
+ let candidate = trimmed;
+
+ if (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']') {
+ candidate = trimmed.slice(1, -1);
+ }
+
+ // Try JSON parsing after normalizing quotes
+ try {
+ let normalized = trimmed;
+ if (trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1) {
+ normalized = trimmed.replace(/'/g, '"');
+ }
+ const parsed = JSON.parse(normalized);
+ if (Array.isArray(parsed)) {
+ result = parsed;
+ }
+ } catch (_) { /* fall back below */ }
+
+ if (!result || !result.length) {
+ result = candidate.split(/\s*,\s*/);
+ }
+
+ return result.map((t) => String(t || '').trim()).filter(Boolean);
+ }
+
+ function deriveTagsFromReasons(reasons: string): string[] {
+ if (!reasons) return [];
+ const out: string[] = [];
+
+ // Grab bracketed or quoted lists first
+ const m = reasons.match(/\[(.*?)\]/);
+ if (m && m[1]) out.push(...m[1].split(/\s*,\s*/));
+
+ // Common phrasing: "overlap(s) with A, B" or "by A, B"
+ const rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig;
+ let r;
+ while ((r = rx.exec(reasons))) {
+ out.push(...(r[2] || '').split(/\s*,\s*/));
+ }
+
+ const tagRx = /tag:\s*([^.;]+)/ig;
+ let tMatch;
+ while ((tMatch = tagRx.exec(reasons))) {
+ out.push(...(tMatch[1] || '').split(/\s*,\s*/));
+ }
+
+ return out.map((s) => s.trim()).filter(Boolean);
+ }
+
+ let overlapArr: string[] = [];
+ if (overlapsAttr) {
+ const parsedOverlaps = parseTagList(overlapsAttr);
+ if (parsedOverlaps.length) {
+ overlapArr = parsedOverlaps;
+ } else {
+ overlapArr = [String(overlapsAttr).trim()];
+ }
+ }
+
+ const derivedFromReasons = deriveTagsFromReasons(reasonsRaw);
+ let allTags = parseTagList(tagsRaw);
+
+ if (!allTags.length && derivedFromReasons.length) {
+ // Fallback: try to derive tags from reasons text when tags missing
+ allTags = derivedFromReasons.slice();
+ }
+
+ if ((!overlapArr || !overlapArr.length) && derivedFromReasons.length) {
+ const normalizedAll = (allTags || []).map((t) => ({ raw: t, key: t.toLowerCase() }));
+ const derivedKeys = new Set(derivedFromReasons.map((t) => t.toLowerCase()));
+ let intersect = normalizedAll.filter((entry) => derivedKeys.has(entry.key)).map((entry) => entry.raw);
+
+ if (!intersect.length) {
+ intersect = derivedFromReasons.slice();
+ }
+
+ overlapArr = Array.from(new Set(intersect));
+ }
+
+ overlapArr = (overlapArr || []).map((t) => String(t || '').trim()).filter(Boolean);
+ allTags = (allTags || []).map((t) => String(t || '').trim()).filter(Boolean);
+
+ nameEl.textContent = nm;
+ rarityEl.textContent = rarity;
+
+ const roleLabel = displayLabel(role);
+ const roleKey = (roleLabel || role || '').toLowerCase();
+ const isCommanderRole = roleKey === 'commander';
+
+ metaEl.textContent = [
+ roleLabel ? ('Role: ' + roleLabel) : '',
+ mana ? ('Mana: ' + mana) : ''
+ ].filter(Boolean).join(' • ');
+
+ reasonsList.innerHTML = '';
+ reasonsRaw.split(';').map((r) => r.trim()).filter(Boolean).forEach((r) => {
+ const li = document.createElement('li');
+ li.style.margin = '2px 0';
+ li.textContent = r;
+ reasonsList.appendChild(li);
+ });
+
+ // Build inline tag list with overlap highlighting
+ if (tagListEl) {
+ tagListEl.innerHTML = '';
+ tagListEl.style.display = 'none';
+ tagListEl.setAttribute('aria-hidden', 'true');
+ }
+
+ if (overlapsEl) {
+ if (overlapArr && overlapArr.length) {
+ overlapsEl.innerHTML = overlapArr.map((o) => {
+ const label = displayLabel(o);
+ return `${label}`;
+ }).join('');
+ } else {
+ overlapsEl.innerHTML = '';
+ }
+ }
+
+ if (tagsEl) {
+ if (isCommanderRole) {
+ tagsEl.textContent = '';
+ tagsEl.style.display = 'none';
+ } else {
+ let tagText = allTags.map(displayLabel).join(', ');
+
+ // M5: Temporarily append metadata tags for debugging
+ if (metadataTagsRaw && metadataTagsRaw.trim()) {
+ const metaTags = metadataTagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
+ if (metaTags.length) {
+ const metaText = metaTags.map(displayLabel).join(', ');
+ tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText);
+ }
+ }
+
+ tagsEl.textContent = tagText;
+ tagsEl.style.display = tagText ? '' : 'none';
+ }
+ }
+
+ if (roleEl) {
+ roleEl.textContent = roleLabel || '';
+ roleEl.style.display = roleLabel ? 'inline-block' : 'none';
+ }
+
+ panel.classList.toggle('is-payoff', role === 'payoff');
+ panel.classList.toggle('is-commander', isCommanderRole);
+
+ const hasDetails = !forceSimple && (
+ !!roleLabel || !!mana || !!rarity ||
+ (reasonsRaw && reasonsRaw.trim()) ||
+ (overlapArr && overlapArr.length) ||
+ (allTags && allTags.length)
+ );
+
+ panel.classList.toggle('hcp-simple', !hasDetails);
+
+ if (rightCol) {
+ rightCol.style.display = hasDetails ? 'flex' : 'none';
+ }
+
+ if (bodyEl) {
+ if (!hasDetails) {
+ bodyEl.style.display = 'flex';
+ bodyEl.style.flexDirection = 'column';
+ bodyEl.style.alignItems = 'center';
+ bodyEl.style.gap = '12px';
+ } else {
+ bodyEl.style.display = '';
+ bodyEl.style.flexDirection = '';
+ bodyEl.style.alignItems = '';
+ bodyEl.style.gap = '';
+ }
+ }
+
+ const rawName = nm || '';
+ let hasBack = rawName.indexOf('//') > -1 || (attr('data-original-name') || '').indexOf('//') > -1;
+ if (hasBack) hasFlip = true;
+
+ const storageKey = 'mtg:face:' + rawName.toLowerCase();
+ const storedFace = (() => {
+ try {
+ return localStorage.getItem(storageKey);
+ } catch (_) {
+ return null;
+ }
+ })();
+
+ if (storedFace === 'front' || storedFace === 'back') {
+ card.setAttribute('data-current-face', storedFace);
+ }
+
+ const chosenFace = card.getAttribute('data-current-face') || 'front';
+ lastCard = card;
+
+ function renderHoverFace(face: string): void {
+ const desiredVersion = 'normal';
+ const currentKey = nm + ':' + face + ':' + desiredVersion;
+ const prevFace = imgEl.getAttribute('data-face');
+ const faceChanged = prevFace && prevFace !== face;
+
+ if (imgEl.getAttribute('data-current') !== currentKey) {
+ // For DFC cards, extract the specific face name for cache lookup
+ let faceName = nm;
+ const isDFC = nm.indexOf('//') > -1;
+ if (isDFC) {
+ const faces = nm.split('//');
+ faceName = (face === 'back') ? faces[1].trim() : faces[0].trim();
+ }
+
+ let src = '/api/images/' + desiredVersion + '/' + encodeURIComponent(faceName);
+ if (isDFC && face === 'back') {
+ src += '?face=back';
+ }
+
+ if (faceChanged) imgEl.style.opacity = '0';
+ prefetch(src);
+ imgEl.src = src;
+ imgEl.setAttribute('data-current', currentKey);
+ imgEl.setAttribute('data-face', face);
+
+ imgEl.addEventListener('load', function onLoad() {
+ imgEl.removeEventListener('load', onLoad);
+ requestAnimationFrame(() => { imgEl.style.opacity = '1'; });
+ });
+ }
+
+ if (!(imgEl as any).__errBound) {
+ (imgEl as any).__errBound = true;
+ imgEl.addEventListener('error', () => {
+ const cur = imgEl.getAttribute('src') || '';
+ // Fallback from normal to small if image fails to load
+ if (cur.indexOf('/api/images/normal/') > -1) {
+ imgEl.src = cur.replace('/api/images/normal/', '/api/images/small/');
+ }
+ });
+ }
+ }
+
+ renderHoverFace(chosenFace);
+
+ // Add DFC flip button to popup panel ONLY on mobile
+ const checkFlip = (window as any).__dfcHasTwoFaces || (() => false);
+ if (hasFlip && imgEl && checkFlip(card) && isMobileMode()) {
+ const imgWrap = imgEl.parentElement;
+ if (imgWrap && !imgWrap.querySelector('.dfc-toggle')) {
+ const flipBtn = document.createElement('button');
+ flipBtn.type = 'button';
+ flipBtn.className = 'dfc-toggle';
+ flipBtn.setAttribute('aria-pressed', 'false');
+ flipBtn.setAttribute('tabindex', '0');
+ flipBtn.innerHTML = '⥮';
+
+ flipBtn.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ if ((window as any).__dfcFlipCard && lastCard) {
+ // For image elements, find the parent container with the flip button
+ let cardToFlip = lastCard;
+ if (lastCard.tagName === 'IMG' && lastCard.parentElement) {
+ const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle');
+ if (parentWithButton) {
+ cardToFlip = lastCard.parentElement;
+ }
+ }
+ (window as any).__dfcFlipCard(cardToFlip);
+ }
+ });
+
+ flipBtn.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'f' || ev.key === 'F') {
+ ev.preventDefault();
+ if ((window as any).__dfcFlipCard && lastCard) {
+ // For image elements, find the parent container with the flip button
+ let cardToFlip = lastCard;
+ if (lastCard.tagName === 'IMG' && lastCard.parentElement) {
+ const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle');
+ if (parentWithButton) {
+ cardToFlip = lastCard.parentElement;
+ }
+ }
+ (window as any).__dfcFlipCard(cardToFlip);
+ }
+ }
+ });
+
+ imgWrap.classList.add('dfc-host');
+ imgWrap.appendChild(flipBtn);
+ }
+ }
+
+ (window as any).__dfcNotifyHover = hasFlip ? (cardRef: Element, face: string) => {
+ if (cardRef === lastCard) renderHoverFace(face);
+ } : null;
+
+ if (evt) (window as any).__lastPointerEvent = evt;
+
+ if (isMobileMode()) {
+ panel.classList.add('mobile');
+ panel.style.pointerEvents = 'auto';
+ panel.style.maxHeight = '80vh';
+ } else {
+ panel.classList.remove('mobile');
+ panel.style.pointerEvents = 'none';
+ panel.style.maxHeight = '';
+ panel.style.bottom = 'auto';
+ }
+
+ panel.style.display = 'block';
+ panel.setAttribute('aria-hidden', 'false');
+ move(evt);
+ }
+
+ function hide(): void {
+ // Blur any focused element inside panel to avoid ARIA focus warning
+ if (panel.contains(document.activeElement)) {
+ (document.activeElement as HTMLElement)?.blur();
+ }
+ panel.style.display = 'none';
+ panel.setAttribute('aria-hidden', 'true');
+ cancelSchedule();
+ panel.classList.remove('mobile');
+ panel.style.pointerEvents = 'none';
+ panel.style.transform = 'none';
+ panel.style.bottom = 'auto';
+ panel.style.maxHeight = '';
+ (window as any).__dfcNotifyHover = null;
+ }
+
+ document.addEventListener('mousemove', move);
+
+ function getCardFromEl(el: EventTarget | null): Element | null {
+ if (!el || !(el instanceof Element)) return null;
+
+ if (el.closest) {
+ const altBtn = el.closest('.alts button[data-card-name]');
+ if (altBtn) return altBtn;
+ }
+
+ // If inside flip button
+ const btn = el.closest && el.closest('.dfc-toggle');
+ if (btn) {
+ return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
+ }
+
+ // For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile)
+ if (el.closest && el.closest('.card-tile')) {
+ const imgBtn = el.closest('.img-btn');
+ if (imgBtn) return imgBtn.closest('.card-tile');
+
+ // If directly on the image
+ if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))) {
+ return el.closest('.card-tile');
+ }
+
+ // Don't trigger on other parts of the tile (buttons, text, etc.)
+ return null;
+ }
+
+ // Recognized container classes
+ const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
+ if (container) return container;
+
+ // Image-based detection (any card image carrying data-card-name)
+ if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) {
+ const up = el.closest && el.closest('.stack-card');
+ return up || el;
+ }
+
+ // List view spans (deck summary list mode, finished deck list, etc.)
+ if (el.hasAttribute && el.hasAttribute('data-card-name')) return el;
+
+ return null;
+ }
+
+ document.addEventListener('pointermove', (e) => { (window as any).__lastPointerEvent = e; });
+
+ document.addEventListener('pointerover', (e) => {
+ if (isMobileMode()) return;
+ const card = getCardFromEl(e.target);
+ if (!card) return;
+
+ // If hovering flip button, refresh immediately (no activation delay)
+ if (e.target instanceof Element && e.target.closest && e.target.closest('.dfc-toggle')) {
+ show(card, e);
+ return;
+ }
+
+ if (lastCard === card && panel.style.display === 'block') return;
+ schedule(card, e);
+ });
+
+ document.addEventListener('pointerout', (e) => {
+ if (isMobileMode()) return;
+ const relCard = getCardFromEl(e.relatedTarget);
+ if (relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
+ if (!panel.contains(e.relatedTarget as Node)) {
+ cancelSchedule();
+ if (!relCard) hide();
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!isMobileMode()) return;
+ if (panel.contains(e.target as Node)) return;
+ if (e.target instanceof Element && e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return;
+ if (e.target instanceof Element && e.target.closest && e.target.closest('button, input, select, textarea, a')) return;
+
+ const card = getCardFromEl(e.target);
+ if (card) {
+ cancelSchedule();
+ const rect = card.getBoundingClientRect();
+ const syntheticEvt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
+ show(card, syntheticEvt);
+ } else if (panel.style.display === 'block') {
+ hide();
+ }
+ });
+
+ // Expose show function for external refresh (flip updates)
+ (window as any).__hoverShowCard = (card: Element) => {
+ const ev = (window as any).__lastPointerEvent || {
+ clientX: card.getBoundingClientRect().left + 12,
+ clientY: card.getBoundingClientRect().top + 12
+ };
+ show(card, ev);
+ };
+
+ (window as any).hoverShowByName = (name: string) => {
+ try {
+ const el = document.querySelector('[data-card-name="' + CSS.escape(name) + '"]');
+ if (el) {
+ (window as any).__hoverShowCard(
+ el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el
+ );
+ }
+ } catch (_) { }
+ };
+
+ // Keyboard accessibility & focus traversal
+ document.addEventListener('focusin', (e) => {
+ const card = e.target instanceof Element && e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb');
+ if (card) {
+ show(card, {
+ clientX: card.getBoundingClientRect().left + 10,
+ clientY: card.getBoundingClientRect().top + 10
+ });
+ }
+ });
+
+ document.addEventListener('focusout', (e) => {
+ const next = e.relatedTarget instanceof Element && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb');
+ if (!next) hide();
+ });
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') hide();
+ });
+
+ // Compact mode event listener
+ document.addEventListener('mtg:hoverCompactToggle', () => {
+ panel.classList.toggle('compact-img', !!(window as any).__hoverCompactMode);
+ });
+ }
+
+ document.addEventListener('htmx:afterSwap', setup);
+ document.addEventListener('DOMContentLoaded', setup);
+ setup();
+};
+
+// Global compact mode toggle function
+(window as any).__initHoverCompactMode = function initHoverCompactMode(): void {
+ (window as any).toggleHoverCompactMode = (state?: boolean) => {
+ if (typeof state === 'boolean') {
+ (window as any).__hoverCompactMode = state;
+ } else {
+ (window as any).__hoverCompactMode = !(window as any).__hoverCompactMode;
+ }
+ document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle'));
+ };
+};
+
+// Auto-initialize on load
+if (typeof window !== 'undefined') {
+ (window as any).__initHoverCardPanel();
+ (window as any).__initHoverCompactMode();
+}
diff --git a/code/web/static/ts/cardImages.ts b/code/web/static/ts/cardImages.ts
new file mode 100644
index 0000000..b7f8455
--- /dev/null
+++ b/code/web/static/ts/cardImages.ts
@@ -0,0 +1,153 @@
+/**
+ * Card Image URL Builders & Retry Logic
+ *
+ * Utilities for constructing card image URLs and handling image load failures
+ * with automatic fallback to different image sizes.
+ *
+ * Features:
+ * - Build card image URLs with face (front/back) support
+ * - Build Scryfall image URLs with version control
+ * - Automatic retry on image load failure (different sizes)
+ * - Cache-busting support for failed loads
+ * - HTMX swap integration for dynamic content
+ *
+ * NOTE: This module exposes functions globally on window for browser compatibility
+ */
+
+interface ImageRetryState {
+ vi: number; // Current version index
+ nocache: number; // Cache-busting flag (0 or 1)
+ versions: string[]; // Image versions to try ['small', 'normal', 'large']
+}
+
+const IMG_FLAG = '__cardImgRetry';
+
+/**
+ * Normalize card name by removing synergy suffixes
+ */
+function normalizeCardName(raw: string): string {
+ if (!raw) return raw;
+ const normalize = (window as any).__normalizeCardName || ((name: string) => {
+ if (!name) return name;
+ const m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(name);
+ if (m) return m[1].trim();
+ return name;
+ });
+ return normalize(raw);
+}
+
+/**
+ * Build card image URL with face support (front/back)
+ * @param name - Card name
+ * @param version - Image version ('small', 'normal', 'large')
+ * @param nocache - Add cache-busting timestamp
+ * @param face - Card face ('front' or 'back')
+ */
+function buildCardUrl(name: string, version?: string, nocache?: boolean, face?: string): string {
+ name = normalizeCardName(name);
+ const q = encodeURIComponent(name || '');
+ let url = '/api/images/' + (version || 'normal') + '/' + q;
+ if (face === 'back') url += '?face=back';
+ if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now();
+ return url;
+}
+
+/**
+ * Build Scryfall image URL
+ * @param name - Card name
+ * @param version - Image version ('small', 'normal', 'large')
+ * @param nocache - Add cache-busting timestamp
+ */
+function buildScryfallImageUrl(name: string, version?: string, nocache?: boolean): string {
+ name = normalizeCardName(name);
+ const q = encodeURIComponent(name || '');
+ let url = '/api/images/' + (version || 'normal') + '/' + q;
+ if (nocache) url += '?t=' + Date.now();
+ return url;
+}
+
+/**
+ * Bind error handler to an image element for automatic retry with fallback versions
+ * @param img - Image element with data-card-name attribute
+ * @param versions - Array of image versions to try in order
+ */
+function bindCardImageRetry(img: HTMLImageElement, versions?: string[]): void {
+ try {
+ if (!img || (img as any)[IMG_FLAG]) return;
+ const name = img.getAttribute('data-card-name') || '';
+ if (!name) return;
+
+ // Default versions: normal -> large
+ const versionList = versions && versions.length ? versions.slice() : ['normal', 'large'];
+ (img as any)[IMG_FLAG] = {
+ vi: 0,
+ nocache: 0,
+ versions: versionList
+ } as ImageRetryState;
+
+ img.addEventListener('error', function() {
+ const st = (img as any)[IMG_FLAG] as ImageRetryState;
+ if (!st) return;
+
+ // Try next version
+ if (st.vi < st.versions.length - 1) {
+ st.vi += 1;
+ img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
+ }
+ // Try cache-busting current version
+ else if (!st.nocache) {
+ st.nocache = 1;
+ img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
+ }
+ });
+
+ // If initial load already failed before binding, try next immediately
+ if (img.complete && img.naturalWidth === 0) {
+ const st = (img as any)[IMG_FLAG] as ImageRetryState;
+ const current = img.src || '';
+ const first = buildScryfallImageUrl(name, st.versions[0], false);
+
+ // Check if current src matches first version
+ if (current.indexOf(encodeURIComponent(name)) !== -1 &&
+ current.indexOf('version=' + st.versions[0]) !== -1) {
+ st.vi = Math.min(1, st.versions.length - 1);
+ img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
+ } else {
+ // Re-trigger current request (may succeed if transient error)
+ img.src = current;
+ }
+ }
+ } catch (_) {
+ // Silently fail - image retry is a nice-to-have feature
+ }
+}
+
+/**
+ * Bind retry handlers to all card images in the document
+ */
+function bindAllCardImageRetries(): void {
+ document.querySelectorAll('img[data-card-name]').forEach((img) => {
+ // Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
+ const versions = (img.classList && img.classList.contains('card-thumb'))
+ ? ['small', 'normal', 'large']
+ : ['normal', 'large'];
+ bindCardImageRetry(img as HTMLImageElement, versions);
+ });
+}
+
+// Expose globally for browser usage
+(window as any).__initCardImages = function initCardImages(): void {
+ // Expose retry binding globally for dynamic content
+ (window as any).bindAllCardImageRetries = bindAllCardImageRetries;
+
+ // Initial bind
+ bindAllCardImageRetries();
+
+ // Re-bind after HTMX swaps
+ document.addEventListener('htmx:afterSwap', bindAllCardImageRetries);
+};
+
+// Auto-initialize on load
+if (typeof window !== 'undefined') {
+ (window as any).__initCardImages();
+}
diff --git a/code/web/static/ts/components.ts b/code/web/static/ts/components.ts
new file mode 100644
index 0000000..b9493b2
--- /dev/null
+++ b/code/web/static/ts/components.ts
@@ -0,0 +1,382 @@
+/**
+ * M3 Component Library - TypeScript Utilities
+ *
+ * Core functions for interactive components:
+ * - Card flip button (dual-faced cards)
+ * - Collapsible panels
+ * - Card popups
+ * - Modal management
+ *
+ * Migrated from components.js with TypeScript types
+ */
+
+// ============================================
+// TYPE DEFINITIONS
+// ============================================
+
+interface CardPopupOptions {
+ tags?: string[];
+ highlightTags?: string[];
+ role?: string;
+ layout?: string;
+}
+
+// ============================================
+// CARD FLIP FUNCTIONALITY
+// ============================================
+
+/**
+ * Flip a dual-faced card image between front and back faces
+ * @param button - The flip button element
+ */
+function flipCard(button: HTMLElement): void {
+ const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
+ if (!container) return;
+
+ const img = container.querySelector('img') as HTMLImageElement | null;
+ 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(): void {
+ document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => {
+ const cardName = img.dataset.cardName;
+ if (!cardName) return;
+
+ const faces = cardName.split(' // ');
+ if (faces.length > 1) {
+ const frontFace = faces[0];
+ const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
+ 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 panelId - The ID of the panel element
+ */
+function togglePanel(panelId: string): void {
+ const panel = document.getElementById(panelId);
+ if (!panel) return;
+
+ const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
+ const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
+ 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 panelId - The ID of the panel element
+ */
+function expandPanel(panelId: string): void {
+ const panel = document.getElementById(panelId);
+ if (!panel) return;
+
+ const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
+ const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
+ 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 panelId - The ID of the panel element
+ */
+function collapsePanel(panelId: string): void {
+ const panel = document.getElementById(panelId);
+ if (!panel) return;
+
+ const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
+ const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
+ 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 modalId - The ID of the modal element
+ */
+function openModal(modalId: string): void {
+ const modal = document.getElementById(modalId);
+ if (!modal) return;
+
+ (modal as HTMLElement).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 modalOrId - Modal element or ID
+ */
+function closeModal(modalOrId: string | HTMLElement): void {
+ 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(): void {
+ document.querySelectorAll('.modal').forEach(modal => modal.remove());
+ document.body.style.overflow = '';
+}
+
+// ============================================
+// CARD POPUP FUNCTIONALITY
+// ============================================
+
+/**
+ * Show card details popup on hover or tap
+ * @param cardName - The card name
+ * @param options - Popup options
+ */
+function showCardPopup(cardName: string, options: CardPopupOptions = {}): void {
+ // 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 element - Element to search from (optional)
+ */
+function closeCardPopup(element?: HTMLElement): void {
+ 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(): void {
+ 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: MouseEvent) {
+ 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: MouseEvent) {
+ 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: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ closeCardPopup();
+
+ // Close topmost modal only
+ const modals = document.querySelectorAll('.modal');
+ if (modals.length > 0) {
+ closeModal(modals[modals.length - 1] as HTMLElement);
+ }
+ }
+ });
+ });
+} else {
+ // DOM already loaded
+ setupCardPopups();
+}
+
+// Make functions globally available for inline onclick handlers
+(window as any).flipCard = flipCard;
+(window as any).resetCardFaces = resetCardFaces;
+(window as any).togglePanel = togglePanel;
+(window as any).expandPanel = expandPanel;
+(window as any).collapsePanel = collapsePanel;
+(window as any).openModal = openModal;
+(window as any).closeModal = closeModal;
+(window as any).closeAllModals = closeAllModals;
+(window as any).showCardPopup = showCardPopup;
+(window as any).closeCardPopup = closeCardPopup;
+(window as any).setupCardPopups = setupCardPopups;
diff --git a/code/web/static/ts/types.ts b/code/web/static/ts/types.ts
new file mode 100644
index 0000000..bb7fb65
--- /dev/null
+++ b/code/web/static/ts/types.ts
@@ -0,0 +1,105 @@
+/* Shared TypeScript type definitions for MTG Deckbuilder web app */
+
+// Toast system types
+export interface ToastOptions {
+ duration?: number;
+}
+
+// State management types
+export interface StateManager {
+ get(key: string, def?: any): any;
+ set(key: string, val: any): void;
+ inHash(obj: Record): void;
+ readHash(): URLSearchParams;
+}
+
+// Telemetry types
+export interface TelemetryManager {
+ send(eventName: string, data?: Record): void;
+}
+
+// Skeleton system types
+export interface SkeletonManager {
+ show(context?: HTMLElement | Document): void;
+ hide(context?: HTMLElement | Document): void;
+}
+
+// Card popup types (from components.ts)
+export interface CardPopupOptions {
+ tags?: string[];
+ highlightTags?: string[];
+ role?: string;
+ layout?: string;
+ showActions?: boolean;
+}
+
+// HTMX event detail types
+export interface HtmxResponseErrorDetail {
+ xhr?: XMLHttpRequest;
+ path?: string;
+ target?: HTMLElement;
+}
+
+export interface HtmxEventDetail {
+ target?: HTMLElement;
+ elt?: HTMLElement;
+ path?: string;
+ xhr?: XMLHttpRequest;
+}
+
+// HTMX cache interface
+export interface HtmxCache {
+ get(key: string): any;
+ set(key: string, html: string, ttl?: number, meta?: any): void;
+ apply(elt: any, detail: any, entry: any): void;
+ buildKey(detail: any, elt: any): string;
+ ttlFor(elt: any): number;
+ prefetch(url: string, opts?: any): void;
+}
+
+// Global window extensions
+declare global {
+ interface Window {
+ __mtgState: StateManager;
+ toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement;
+ toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement;
+ appTelemetry: TelemetryManager;
+ skeletons: SkeletonManager;
+ __telemetryEndpoint?: string;
+ showCardPopup?: (cardName: string, options?: CardPopupOptions) => void;
+ dismissCardPopup?: () => void;
+ flipCard?: (button: HTMLElement) => void;
+ htmxCache?: HtmxCache;
+ htmx?: any; // HTMX library - use any for external library
+ initHtmxDebounce?: () => void;
+ scrollCardIntoView?: (card: HTMLElement) => void;
+ __virtGlobal?: any;
+ __virtHotkeyBound?: boolean;
+ }
+
+ interface CustomEvent {
+ readonly detail: T;
+ }
+
+ // HTMX custom events
+ interface DocumentEventMap {
+ 'htmx:responseError': CustomEvent;
+ 'htmx:sendError': CustomEvent;
+ 'htmx:afterSwap': CustomEvent;
+ 'htmx:beforeRequest': CustomEvent;
+ 'htmx:afterSettle': CustomEvent;
+ 'htmx:afterRequest': CustomEvent;
+ }
+
+ interface HTMLElement {
+ __hxCacheKey?: string;
+ __hxCacheTTL?: number;
+ }
+
+ interface Element {
+ __hxPrefetched?: boolean;
+ }
+}
+
+// Empty export to make this a module file
+export {};
diff --git a/code/web/templates/base.html b/code/web/templates/base.html
index 72996c3..c17b53f 100644
--- a/code/web/templates/base.html
+++ b/code/web/templates/base.html
@@ -6,10 +6,6 @@
MTG Deckbuilder
-
+
+
+
+
\ 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..dfea715 100644
--- a/code/web/templates/build/_step1.html
+++ b/code/web/templates/build/_step1.html
@@ -39,7 +39,7 @@
@@ -66,8 +66,8 @@
{% endif %}
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
-
- No results for “{{ query }}”. Try a shorter name or a different spelling.
+
+ No results for "{{ query }}". Try a shorter name or a different spelling.
{% endif %}
@@ -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 }}
-