diff --git a/.env.example b/.env.example index 5921ede..4eef7c2 100644 --- a/.env.example +++ b/.env.example @@ -56,7 +56,6 @@ WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1 ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1" SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1" SIMILARITY_CACHE_PATH="card_files/similarity_cache.parquet" # Path to Parquet cache file -ENABLE_BATCH_BUILD=1 # dockerhub: ENABLE_BATCH_BUILD="1" (enable Build X and Compare feature) ############################ # Partner / Background Mechanics @@ -106,9 +105,6 @@ 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 6de24ec..f8e1a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ RELEASE_NOTES.md test.py -test_*.py !test_exclude_cards.txt !test_include_exclude_config.json @@ -41,14 +40,4 @@ logs/ logs/* !logs/perf/ logs/perf/* -!logs/perf/theme_preview_warm_baseline.json - -# Node.js and build artifacts -node_modules/ -code/web/static/js/ -code/web/static/styles.css -*.js.map - -# Keep TypeScript sources and Tailwind CSS input -!code/web/static/ts/ -!code/web/static/tailwind.css \ No newline at end of file +!logs/perf/theme_preview_warm_baseline.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2351a17..0172a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,106 +9,19 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -- **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 +_None_ ### 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 - - 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 - -### Changed -- 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 +_None_ ### Removed _None_ -### Performance -- 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 +### Fixed +_None_ -### For Users -- Faster card image loading with optional caching -- Cleaner, more consistent web UI design -- Improved page load performance -- More reliable JavaScript behavior +### Performance +_None_ ### Deprecated _None_ diff --git a/DOCKER.md b/DOCKER.md index 99c9907..6a5ba07 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -258,7 +258,6 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | | `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/`. | | `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. | -| `ENABLE_BATCH_BUILD` | `1` | Enable Build X and Compare feature (build multiple decks in parallel and compare results). | ### Random build controls @@ -283,7 +282,6 @@ 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 1f76105..7f6f0ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,42 +10,21 @@ ENV PYTHONUNBUFFERED=1 ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} -# Install system dependencies including Node.js +# Install system dependencies if needed 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 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 first for better caching COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy Python application code (includes templates needed for Tailwind) +# Copy application code 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 @@ -57,9 +36,7 @@ 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) -# 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/ +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 5d46b02..5cd9338 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,6 @@ Every tile on the homepage connects to a workflow. Use these sections as your to ### Build a Deck Start here for interactive deck creation. - Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal. -- **Build X and Compare** (`ENABLE_BATCH_BUILD=1`, default): Build 1-10 decks with the same configuration to see variance - - Parallel execution (max 5 concurrent) with real-time progress and dynamic time estimates - - Comparison view shows card overlap statistics and individual build summaries - - **Synergy Builder**: Analyze builds and create optimized "best-of" deck scored by frequency, EDHREC rank, and theme tags - - Rebuild button for quick iterations, ZIP export for all builds - - See `docs/user_guides/batch_build_compare.md` for full guide - **Quick Build**: One-click automation runs the full workflow with live progress (Creatures → Spells → Lands → Final Touches → Summary). Available in New Deck wizard. - **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: land steps, creature stages, spell categories). Auto-advance without approval prompts. - Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`. @@ -309,7 +303,6 @@ 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 f03d5c5..7b914a9 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,106 +3,25 @@ ## [Unreleased] ### Summary -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. +_No unreleased changes yet_ ### Added -- **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 +_None_ ### 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 -- **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 +_None_ ### Removed _None_ ### Fixed -- **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 +_None_ ### Performance -- 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 +_None_ ### For Users -- Faster card image loading with optional caching -- Cleaner, more consistent web UI design -- Improved page load performance -- More reliable JavaScript behavior +_No changes yet_ ### Deprecated _None_ diff --git a/code/deck_builder/__init__.py b/code/deck_builder/__init__.py index 9540709..c992bac 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 + from .builder import DeckBuilder # type: ignore return DeckBuilder raise AttributeError(name) diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py index b941f30..86dedd4 100644 --- a/code/deck_builder/background_loader.py +++ b/code/deck_builder/background_loader.py @@ -1,18 +1,22 @@ -"""Loader for background cards derived from all_cards.parquet.""" +"""Loader for background cards derived from `background_cards.csv`.""" from __future__ import annotations import ast -import re +import csv from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from typing import Any, Mapping, Tuple +import re +from typing import 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: @@ -53,7 +57,7 @@ class BackgroundCatalog: def load_background_cards( source_path: str | Path | None = None, ) -> BackgroundCatalog: - """Load and cache background card data from all_cards.parquet.""" + """Load and cache background card data.""" resolved = _resolve_background_path(source_path) try: @@ -61,7 +65,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 data not found at {resolved}") from None + raise FileNotFoundError(f"Background CSV not found at {resolved}") from None entries, version = _load_background_cards_cached(str(resolved), mtime_ns) etag = f"{size}-{mtime_ns}-{len(entries)}" @@ -84,49 +88,46 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B if not path.exists(): return tuple(), "unknown" - 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" + 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) 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() - # Use card_files/processed/all_cards.parquet - return Path("card_files/processed/all_cards.parquet").resolve() + return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve() -def _rows_to_cards(df) -> list[BackgroundCard]: - """Convert DataFrame rows to BackgroundCard objects.""" +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]: entries: list[BackgroundCard] = [] seen: set[str] = set() - - for _, row in df.iterrows(): - if row.empty: + for raw in reader: + if not raw: continue - card = _row_to_card(row) + card = _row_to_card(raw) if card is None: continue key = card.display_name.lower() @@ -134,35 +135,20 @@ def _rows_to_cards(df) -> list[BackgroundCard]: continue seen.add(key) entries.append(card) - entries.sort(key=lambda card: card.display_name) return entries -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 +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 display = face_name or name if not display: return None - type_line = _clean_str(get_val("type")) - oracle_text = _clean_multiline(get_val("text")) - raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags"))) + type_line = _clean_str(row.get("type")) + oracle_text = _clean_multiline(row.get("text")) + raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags"))) detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags) if not detection.is_background: return None @@ -172,18 +158,18 @@ def _row_to_card(row) -> BackgroundCard | None: face_name=face_name, display_name=display, slug=_slugify(display), - 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")), + 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")), type_line=type_line, oracle_text=oracle_text, - keywords=tuple(_split_list(get_val("keywords"))), + keywords=tuple(_split_list(row.get("keywords"))), theme_tags=tuple(tag for tag in raw_theme_tags if tag), raw_theme_tags=raw_theme_tags, - edhrec_rank=_parse_int(get_val("edhrecRank")), - layout=_clean_str(get_val("layout")) or "normal", - side=_clean_str(get_val("side")) or None, + edhrec_rank=_parse_int(row.get("edhrecRank")), + layout=_clean_str(row.get("layout")) or "normal", + side=_clean_str(row.get("side")) or None, ) @@ -203,19 +189,8 @@ def _clean_multiline(value: object) -> str: def _parse_literal_list(value: object) -> list[str]: if value is None: return [] - - # 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: + if isinstance(value, (list, tuple, set)): return [str(item).strip() for item in value if str(item).strip()] - text = str(value).strip() if not text: return [] @@ -230,17 +205,6 @@ 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 [] @@ -249,18 +213,6 @@ 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 a7eadd7..50d899e 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 + from random_util import set_seed as _set_seed # type: ignore 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 + from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore 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 + from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore 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 + self.last_csv_path = csv_path # type: ignore[attr-defined] 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') + txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] try: - self.last_txt_path = txt_path + self.last_txt_path = txt_path # type: ignore[attr-defined] 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) + report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] # 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 + from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore 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') + self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined] 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') + self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined] 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) + self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined] 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 - self.last_txt_path = None + self.last_csv_path = None # type: ignore[attr-defined] + self.last_txt_path = None # type: ignore[attr-defined] 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) - rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) + 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] 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 = [] + MULTIPLE_COPY_CARDS = [] # type: ignore 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) + rng.shuffle(bucket_keys) # type: ignore else: random.shuffle(bucket_keys) except Exception: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 02e2054..dd664d3 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 +from typing import Dict, List, Final, Tuple, Union, Callable, Any as _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, Any]] = { +COMMANDER_CONVERTERS: Final[Dict[str, str]] = { '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, Any]]] = { +CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { '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, Any]]] = { +SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { 'manaCost': {'type': 'str', 'required': True}, 'text': {'type': 'str', 'required': True} } -LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { +LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { '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, Any]]] = { +CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = { '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: { tagsAny: list[str], tags_all: list[str] } +# - triggers: { tags_any: 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': { - 'tagsAny': ['artificer kindred', 'hero kindred', 'artifacts matter'], + 'tags_any': ['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': { - 'tagsAny': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], + 'tags_any': ['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': { - 'tagsAny': ['rabbit kindred','tokens matter','aggro'], + 'tags_any': ['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': { - 'tagsAny': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], + 'tags_any': ['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': { - 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tags_any': ['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': { - 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tags_any': ['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': { - 'tagsAny': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], + 'tags_any': ['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': { - 'tagsAny': ['mill','advisor kindred','control','defenders','walls','draw-go'], + 'tags_any': ['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': { - 'tagsAny': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], + 'tags_any': ['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': { - 'tagsAny': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], + 'tags_any': ['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': { - 'tagsAny': ['bird kindred','aggro'], + 'tags_any': ['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': { - 'tagsAny': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], + 'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], 'tags_all': [] }, 'default_count': 25, @@ -956,4 +956,3 @@ 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 a47101e..36ab3fe 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -62,32 +62,6 @@ 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: @@ -170,9 +144,7 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: return {} # Select only needed columns - # 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'] + usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName'] available_cols = [col for col in usecols if col in df.columns] if not available_cols: return {} @@ -188,16 +160,7 @@ 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) - # 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)] + 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]] = {} @@ -206,78 +169,6 @@ 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() @@ -425,7 +316,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(): + for _, r in full_df.iterrows(): # type: ignore[attr-defined] nm = str(r.get('name', '')) if nm and nm not in lookup: lookup[nm] = r @@ -441,13 +332,8 @@ 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) or ('land' in back_type) + is_land = ('land' in entry_type) or ('land' in tline_full) base_is_land = is_land text_field_raw = '' if hasattr(row, 'get'): @@ -477,8 +363,7 @@ 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.) - 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 + # Skip obvious non-permanents (rituals etc.) 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 @@ -552,12 +437,6 @@ 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 @@ -850,7 +729,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(): + for _, row in df.iterrows(): # type: ignore[attr-defined] try: name = str(row.get('name','')) if not name or name in already or name in basics: @@ -1114,7 +993,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]: +def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override] """Return a list of suggestion dicts based on selected commander tags. Each dict fields: @@ -1202,7 +1081,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(): + for _, row in combined_df.iterrows(): # type: ignore[attr-defined] 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 ecc9395..0f0ef17 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"]) + sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined] # 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}) + sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined] 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) + cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type] 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 4ec59fc..f5808bc 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 + import pandas as _pd # type: ignore 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): + if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore columns = [col for col in ("name", "faceName") if col in df.columns] for col in columns: series = df[col].astype(str).str.casefold() @@ -363,14 +363,7 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]: def _normalize_string_sequence(value: Any) -> tuple[str, ...]: if value is None: return tuple() - # 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: + if isinstance(value, (list, tuple, set)): 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 a23f96c..d464204 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 + from rapidfuzz import process as rf_process, fuzz as rf_fuzz # type: ignore _FUZZ_BACKEND = "rapidfuzz" except ImportError: # pragma: no cover - environment dependent try: - from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz + from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz # type: ignore _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 6cdead5..98f196c 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: + def choose_commander(self) -> str: # type: ignore[override] 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: + def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override] 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): + def _apply_commander_selection(self, row: pd.Series): # type: ignore[override] 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]: + def select_commander_tags(self) -> List[str]: # type: ignore[override] 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]: + def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override] 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): + def _update_commander_dict_with_selected_tags(self): # type: ignore[override] 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: + def select_power_bracket(self) -> BracketDefinition: # type: ignore[override] 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): + def _print_bracket_details(self): # type: ignore[override] 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): + def _print_selected_bracket_summary(self): # type: ignore[override] 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 36b1586..ccf0a3f 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): + def add_basic_lands(self): # type: ignore[override] """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') - land_total = self.ideal_counts.get('lands') + basic_min = self.ideal_counts.get('basic_lands') # type: ignore[attr-defined] + land_total = self.ideal_counts.get('lands') # type: ignore[attr-defined] 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): + def run_land_step1(self): # type: ignore[override] """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 713c1f4..7db15f2 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): + def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override] """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) - basic_floor = self._basic_floor(min_basic_cfg) + 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] default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6) - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] 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: - target_basic = self._choose_basic_to_trim() - if not target_basic or not self._decrement_card(target_basic): + 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] break freed_slots += 1 if freed_slots == 0: desired = 0 - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] 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) + rng.shuffle(bucket_keys) # type: ignore 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: + if self._current_land_count() >= land_target: # type: ignore[attr-defined] 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}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step5(self, requested_count: int | None = None): + def run_land_step5(self, requested_count: int | None = None): # type: ignore[override] self.add_dual_lands(requested_count=requested_count) - self._enforce_land_cap(step_label="Duals (Step 5)") + self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined] 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 4dcf54b..57de480 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): + def add_fetch_lands(self, requested_count: int | None = None): # type: ignore[override] """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) - current = self._current_land_count() + 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] 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) + desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) # type: ignore[attr-defined] 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) - floor_basics = self._basic_floor(min_basic_cfg) + 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] slots_needed = desired - 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): + 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] break slots_needed -= 1 - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] 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()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] 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) + return (rng.sample if rng else random.sample)(pool, k) # type: ignore 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: + if self._current_land_count() >= land_target: # type: ignore[attr-defined] 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)) + setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined] 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}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step4(self, requested_count: int | None = None): + def run_land_step4(self, requested_count: int | None = None): # type: ignore[override] """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)") + self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined] 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 2b361c7..bca1827 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): + def add_kindred_lands(self): # type: ignore[override] """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)) + land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) # type: ignore[attr-defined] 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) - basic_floor = self._basic_floor(min_basic_cfg) + 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] def ensure_capacity() -> bool: - if self._current_land_count() < land_target: + if self._current_land_count() < land_target: # type: ignore[attr-defined] return True - if self._count_basic_lands() <= basic_floor: + if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] return False - target_basic = self._choose_basic_to_trim() + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] if not target_basic: return False - if not self._decrement_card(target_basic): + if not self._decrement_card(target_basic): # type: ignore[attr-defined] return False - return self._current_land_count() < land_target + return self._current_land_count() < land_target # type: ignore[attr-defined] 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: + if name in self.card_library: # type: ignore[attr-defined] 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: + if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] 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: + if not nm or nm in self.card_library: # type: ignore[attr-defined] 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: + if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] 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}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step3(self): + def run_land_step3(self): # type: ignore[override] """Public wrapper to add kindred-focused lands.""" self.add_kindred_lands() - self._enforce_land_cap(step_label="Kindred (Step 3)") + self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined] 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 4d0cbef..a12ce0d 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): + def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override] # --- 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): + def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override] 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): + def _build_tag_driven_land_suggestions(self): # type: ignore[override] suggestions = bu.build_tag_driven_suggestions(self) if suggestions: self.suggested_lands_queue.extend(suggestions) - def _apply_land_suggestions_if_room(self): + def _apply_land_suggestions_if_room(self): # type: ignore[override] 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 9c32129..c74d411 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): + def optimize_tapped_lands(self): # type: ignore[override] 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): + def run_land_step8(self): # type: ignore[override] 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 159319c..8d2e21c 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: + def _current_land_count(self) -> int: # type: ignore[override] """Return total number of land cards currently in the library (counts duplicates).""" total = 0 - for name, entry in self.card_library.items(): + for name, entry in self.card_library.items(): # type: ignore[attr-defined] 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): + def add_staple_lands(self): # type: ignore[override] """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') + land_target = self.ideal_counts.get('lands') # type: ignore[attr-defined] 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) - basic_floor = self._basic_floor(min_basic_cfg) + 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] def ensure_capacity() -> bool: - if self._current_land_count() < land_target: + if self._current_land_count() < land_target: # type: ignore[attr-defined] return True - if self._count_basic_lands() <= basic_floor: + if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] return False - target_basic = self._choose_basic_to_trim() + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] if not target_basic: return False - if not self._decrement_card(target_basic): + if not self._decrement_card(target_basic): # type: ignore[attr-defined] return False - return self._current_land_count() < land_target + return self._current_land_count() < land_target # type: ignore[attr-defined] 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: + if land_name in self.card_library: # type: ignore[attr-defined] 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}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step2(self): + def run_land_step2(self): # type: ignore[override] """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" self.add_staple_lands() - self._enforce_land_cap(step_label="Staples (Step 2)") + self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined] 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 8c86bbc..97fbcd5 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(): + for _, row in df.iterrows(): # type: ignore 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 e10b02c..fe380af 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() + context = self.get_theme_context() # type: ignore[attr-defined] 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 + import pandas as _pd # type: ignore 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 a0a0f90..632806d 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 + import pandas as _pd # type: ignore 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() + context = self.get_theme_context() # type: ignore[attr-defined] 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 3044736..97e691b 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 + from prettytable import PrettyTable # type: ignore 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 + from deck_builder.enforcement import enforce_bracket_compliance # type: ignore 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() + self.fill_remaining_theme_spells() # type: ignore[attr-defined] 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) - self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) + 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] # 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) + self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # type: ignore[attr-defined] # Recompute and write compliance next to them - self.compute_and_print_compliance(base_stem=base_stem) + self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined] # 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() + csv_path = self.export_decklist_csv() # type: ignore[attr-defined] 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) + self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined] # Re-export JSON config after enforcement changes if base_only: - self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) + self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) # type: ignore[attr-defined] if base_only: - self.compute_and_print_compliance(base_stem=base_only) + self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined] # 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 + from deck_builder.brackets_compliance import evaluate_deck # type: ignore 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() + summary = self.build_deck_summary() # type: ignore[attr-defined] 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(): + for _, r in snapshot.iterrows(): # type: ignore[attr-defined] 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 + from deck_builder import builder_utils as _builder_utils # type: ignore builder_utils_module = _builder_utils color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) except Exception: @@ -543,9 +543,6 @@ 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, @@ -684,14 +681,13 @@ class ReportingMixin: 'faces': faces_meta, 'layout': layout_val, }) - # M9: Count ALL MDFC lands for land summary - dfc_extra_total += copies + if adds_extra: + 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, # M9: Count of all MDFC lands + 'dfc_lands': dfc_extra_total, '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), @@ -856,7 +852,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')) @@ -1128,7 +1124,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')) @@ -1136,7 +1132,7 @@ class ReportingMixin: row_lookup[nm] = r try: - from deck_builder import builder_utils as _builder_utils + from deck_builder import builder_utils as _builder_utils # type: ignore color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} @@ -1387,4 +1383,3 @@ 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 8b00d40..6f9526d 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 + return index_map # type: ignore[return-value] 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") + req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined] 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 + from headless_runner import run as _run # type: ignore 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() + summary = builder.build_deck_summary() # type: ignore[attr-defined] 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() + commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined] 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) - txt_path = getattr(builder, 'last_txt_path', None) + csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] + txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] 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)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] 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() + tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined] 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') + tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined] 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)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] 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 3bd38a3..6afa02c 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 []: + for raw in values or []: # type: ignore[arg-type] 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 7d1214b..c4d20ac 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 + from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore try: catalog = ThemeCatalog.model_validate(payload) diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py deleted file mode 100644 index 08a7c22..0000000 --- a/code/file_setup/image_cache.py +++ /dev/null @@ -1,567 +0,0 @@ -""" -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 104aa06..b377017 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 + import inquirer # type: ignore 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 247597f..c48dc9d 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 + import inquirer # type: ignore 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 deleted file mode 100644 index fd41d90..0000000 --- a/code/file_setup/scryfall_bulk_data.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -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 62a8165..0b01e21 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -349,44 +349,6 @@ 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 ff3bfbc..0292ccd 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 + from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found] 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) + builder.set_seed(seed) # type: ignore[attr-defined] except Exception: pass # Mark this run as headless so builder can adjust exports and logging try: - builder.headless = True + builder.headless = True # type: ignore[attr-defined] 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 - builder.requested_secondary_commander = secondary_clean or None - builder.requested_background = background_clean or None + 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] 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 []) - builder.exclude_cards = list(exclude_cards or []) - builder.enforcement_mode = enforcement_mode - builder.allow_illegal = allow_illegal - builder.fuzzy_matching = fuzzy_matching + 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] except Exception: pass @@ -336,16 +336,16 @@ def run( ) try: - 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 + 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] if user_theme_weight is not None: - builder.user_theme_weight = float(user_theme_weight) + builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined] 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 + iv = int(v) if v is not None else None # type: ignore 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) + builder.ideal_counts.update(ic) # type: ignore[attr-defined] 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 + builder.combined_commander = combined_commander # type: ignore[attr-defined] except Exception: pass try: - builder.partner_mode = combined_commander.partner_mode + builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined] except Exception: pass try: - builder.secondary_commander = combined_commander.secondary_name + builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined] except Exception: pass try: - builder.combined_color_identity = combined_commander.color_identity - builder.combined_theme_tags = combined_commander.theme_tags - builder.partner_warnings = combined_commander.warnings + 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] 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 + builder.last_csv_path = csv_path # type: ignore[attr-defined] 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 + builder.last_txt_path = txt_generated # type: ignore[attr-defined] 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 + builder.last_txt_path = txt_generated # type: ignore[attr-defined] 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 4f2f722..43c70ca 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 ( + from scripts.extract_themes import ( # type: ignore 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 ( + from extract_themes import ( # type: ignore 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 + from scripts.export_themes_to_yaml import slugify as slugify_theme # type: ignore 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 + from type_definitions_theme_catalog import ThemeCatalog # type: ignore 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 - export_main(['--force']) + from scripts.export_themes_to_yaml import main as export_main # type: ignore + export_main(['--force']) # type: ignore[arg-type] 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') + meta_block = raw.get('provenance') # type: ignore 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 6f1d904..a417e53 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 +from scripts.extract_themes import derive_synergies_for_tags # type: ignore 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 c4c1216..c45e7c5 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 -from code.tagging import tag_constants +from code.settings import CSV_DIRECTORY # type: ignore +from code.tagging import tag_constants # type: ignore BASE_COLORS = { 'white': 'W', diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py index 0ee68d4..39f197b 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 + from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore 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 795bc62..2af36c0 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", []): + for tags in df.get("_ltags", []): # type: ignore[assignment] 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 19b4634..c9f107e 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) + tagger = importlib.reload(tagger) # type: ignore[assignment] 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 09140ae..1b3833f 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 ( # noqa: E402 +from deck_builder.random_entrypoint import ( # type: ignore # 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 ca878f2..3c49af0 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(): + if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): # type: ignore 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) + globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # type: ignore # Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards) - if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] + 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 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(): - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] + if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore + freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore 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 c6b3627..1b18962 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 -from scripts.extract_themes import load_whitelist_config -from scripts.build_theme_catalog import build_catalog +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 CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' diff --git a/code/settings.py b/code/settings.py index fb1caa9..445ed61 100644 --- a/code/settings.py +++ b/code/settings.py @@ -89,8 +89,11 @@ COLUMN_ORDER = CARD_COLUMN_ORDER TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS -# MAIN_MENU_ITEMS, SETUP_MENU_ITEMS, CSV_DIRECTORY already defined above (lines 67-70) +MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit'] +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 # ---------------------------------------------------------------------------------- @@ -108,7 +111,11 @@ 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') -# FILL_NA_COLUMNS already defined above (lines 75-78) +# 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 +} # ---------------------------------------------------------------------------------- # ALL CARDS CONSOLIDATION FEATURE FLAG @@ -153,7 +160,4 @@ SIMILARITY_CACHE_MAX_AGE_DAYS = int(os.getenv('SIMILARITY_CACHE_MAX_AGE_DAYS', ' # Allow downloading pre-built cache from GitHub (saves 15-20 min build time) # Set to '0' to always build locally (useful for custom seeds or offline environments) -SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') - -# Batch build feature flag (Build X and Compare) -ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file +SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file diff --git a/code/tagging/bracket_policy_applier.py b/code/tagging/bracket_policy_applier.py index 5265dd7..80c63b0 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 + import importlib.util # type: ignore 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) - logging_util = mod + spec.loader.exec_module(mod) # type: ignore[assignment] + logging_util = mod # type: ignore 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 deb31ac..0dd2753 100644 --- a/code/tagging/multi_face_merger.py +++ b/code/tagging/multi_face_merger.py @@ -240,13 +240,6 @@ 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 db31b43..b805102 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: + try: # type: ignore[name-defined] _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 3251bf6..cc08214 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: + try: # type: ignore[name-defined] _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 17ad9c8..d7d5dfe 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) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] assert spec and spec.loader - spec.loader.exec_module(mod) + spec.loader.exec_module(mod) # type: ignore[assignment] 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 0969bf3..8a734ed 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() - card_index._CARD_INDEX_MTIME = None + card_index._CARD_INDEX.clear() # type: ignore + card_index._CARD_INDEX_MTIME = None # type: ignore 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 337edf7..d61387a 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 +from code.web.app import app # type: ignore 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 d978252..d566da4 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 +from code.web.app import app # type: ignore 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 bf724f7..6f4d064 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 +from code.web.app import app # type: ignore 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 7ac06c5..4d38a2b 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 + import code.web.app as app_module # type: ignore 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 83b1494..e3713e0 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 + from scripts.build_theme_catalog import build_catalog # type: ignore 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 + import yaml # type: ignore 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 + import yaml # type: ignore 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 d9aaec3..0dd2815 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 + import code.web.app as app_module # type: ignore 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 b08ed16..9fddcb2 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 + self.output_func = self.output_lines.append # type: ignore[assignment] 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 535f8da..e78dafa 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) + builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined] 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 3538e6c..b7cdc4d 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 = { + b._web_multi_copy = { # type: ignore[attr-defined] "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 dfa8b7f..e7a37c7 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 = { + b._web_multi_copy = { # type: ignore[attr-defined] "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 4054fc0..886b277 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 + b._web_multi_copy = selection # type: ignore[attr-defined] b.card_library = {} ctx = { diff --git a/code/tests/test_multicopy_web_flow.py b/code/tests/test_multicopy_web_flow.py index 52f64c2..22fb79a 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 + from starlette.testclient import TestClient # type: ignore 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 5180329..a54838f 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) + request = Request(scope, receive=_receive) # type: ignore[arg-type] 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 - original_cache = partner_service._DATASET_CACHE - original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED + 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] partner_service.DEFAULT_DATASET_PATH = dataset_path - partner_service._DATASET_PATH = dataset_path - partner_service._DATASET_CACHE = None - partner_service._DATASET_REFRESH_ATTEMPTED = True + 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] calls = {"count": 0} payload_path = tmp_path / "seed_dataset.json" _write_dataset(payload_path) - def seeded_refresh(out_func=None, *, force=False, root=None): + def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override] 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 - partner_service._DATASET_CACHE = original_cache - partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted + 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] 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 984b79a..cf3c2e1 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): + def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def] 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 afe616e..34e8c1e 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 + import code.web.app as app_module # type: ignore 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 fc81d13..9839784 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 +from code.web.app import app # type: ignore 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 337b6c2..63447d5 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 -from code.web.services import preview_cache as pc -from code.web.services.preview_metrics import preview_metrics +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 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 + pc._EVICT_WEIGHTS_CACHE = None # type: ignore # 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 804c2d5..848bcce 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 -from code.web.services import preview_cache as pc +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 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 + from code.web.services.preview_metrics import preview_metrics # type: ignore 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 b134a23..2fec530 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 +from code.web.app import app # type: ignore 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 a0bdb9a..50b7ee5 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): + def fake_fetch(url): # type: ignore[override] 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): + def fake_fetch_with_retry(url, attempts=3, delay=0.6): # type: ignore[override] 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 bea1467..9ab5283 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 +from code.web.app import app # type: ignore 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 aa952d3..e4b72b7 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: - pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) + 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] def test_ttl_adapts_down_and_up(capsys): # Enable adaptation regardless of env - pc._ADAPTATION_ENABLED = True - pc.TTL_SECONDS = pc._TTL_BASE - pc._RECENT_HITS.clear() - pc._LAST_ADAPT_AT = None + 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] # 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 + assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined] # Force interval elapsed & high hit ratio pattern (~0.9) _force_interval_elapsed() - pc._RECENT_HITS.clear() + pc._RECENT_HITS.clear() # type: ignore[attr-defined] 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 6fb2e30..6a18061 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 + from code.web import app as app_module # type: ignore # Force override constants for deterministic test try: - 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 + 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] # Reset in-memory counters if hasattr(app_module, '_RL_COUNTS'): - app_module._RL_COUNTS.clear() + app_module._RL_COUNTS.clear() # type: ignore[attr-defined] 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 5c71326..5602ba4 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 -from code.web.app import app +from code.web import app as web_app # type: ignore +from code.web.app import app # type: ignore # 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 711c856..2f09806 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() + card_index._CARD_INDEX.clear() # type: ignore theme = "Test Theme" - card_index._CARD_INDEX[theme] = [ + card_index._CARD_INDEX[theme] = [ # type: ignore {"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 + sampling.maybe_build_index = no_build # type: ignore 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")] + rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore 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() + card_index._CARD_INDEX.clear() # type: ignore 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] - sampling.maybe_build_index = lambda: None + card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore + sampling.maybe_build_index = lambda: None # type: ignore 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"]) + assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore diff --git a/code/tests/test_scryfall_name_normalization.py b/code/tests/test_scryfall_name_normalization.py index f4a6834..cdd7c09 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 +from code.web.services.theme_preview import get_theme_preview # type: ignore # 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 080a6bb..291e3ca 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 + import code.web.app as app_module # type: ignore 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 e61252c..0afa5d8 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 +from code.web.app import app # type: ignore # 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 9badfc2..81f6634 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]) + expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined] 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 9cdd9c8..bc661cf 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 +from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore 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 3bff64c..eb8593b 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 + from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore 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 0146cce..6e7f5c9 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 + from code.web.app import app # type: ignore 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 + import code.web.services.theme_preview as tp # type: ignore 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 + import code.web.services.theme_preview as tp # type: ignore # 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 33aff75..f9a848f 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 + from code.web.app import app # type: ignore return TestClient(app) diff --git a/code/tests/test_theme_preview_ordering.py b/code/tests/test_theme_preview_ordering.py index f0143f5..5cbebdf 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 -from code.web.services.theme_catalog_loader import load_index, slugify, project_detail +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 @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 a35956f..171893d 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 +from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore 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 637940a..e95d60b 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: + def get_theme_context(self) -> ThemeContext: # type: ignore[override] return self._theme_context - def add_card(self, name: str, **kwargs: Any) -> None: + def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override] 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 655f081..703dd9f 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 + from code.web.app import app # type: ignore 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 dbcae13..da88ae0 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: + def model_post_init(self, __context: Any) -> None: # type: ignore[override] # 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: + def model_post_init(self, __context: Any) -> None: # type: ignore[override] 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 77f4f7c..437be4b 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -19,12 +19,9 @@ 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 -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__) +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 # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -56,18 +53,18 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue except Exception: pass try: - commanders_routes.prewarm_default_page() + commanders_routes.prewarm_default_page() # type: ignore[attr-defined] 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 + from .services.card_index import maybe_build_index # type: ignore 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 + from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore get_theme_catalog() # Fast: just reads CSV get_theme_index() # Slower: parses cards for theme-to-card mapping except Exception: @@ -76,7 +73,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 + from .routes.card_browser import get_similarity # type: ignore get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) except Exception: pass @@ -89,7 +86,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): + async def get_response(self, path, scope): # type: ignore[override] resp = await super().get_response(path, scope) try: # Add basic cache headers for static assets @@ -102,38 +99,12 @@ 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): +def _compat_template_response(*args, **kwargs): # type: ignore[override] try: if args and isinstance(args[0], str): name = args[0] @@ -151,7 +122,7 @@ def _compat_template_response(*args, **kwargs): pass return _orig_template_response(*args, **kwargs) -templates.TemplateResponse = _compat_template_response +templates.TemplateResponse = _compat_template_response # type: ignore[assignment] # (Startup prewarm moved to lifespan handler _lifespan) @@ -175,7 +146,6 @@ ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True) ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True) -ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy) RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True) THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False) @@ -327,7 +297,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 + from .services.theme_catalog_loader import CATALOG_JSON # type: ignore if CATALOG_JSON.exists(): raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") meta = raw.get("metadata_info") or {} @@ -869,12 +839,6 @@ 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(): @@ -951,7 +915,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 + from deck_builder.random_entrypoint import get_theme_tag_stats # type: ignore stats = get_theme_tag_stats() return JSONResponse({"ok": True, "stats": stats}) @@ -1038,8 +1002,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 - from deck_builder.random_entrypoint import RandomThemeNoMatchError + from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore + from deck_builder.random_entrypoint import RandomThemeNoMatchError # type: ignore res = build_random_deck( theme=theme, @@ -1170,7 +1134,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 + from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1394,7 +1358,7 @@ async def api_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed + from random_util import generate_seed # type: ignore new_seed = int(generate_seed()) # Build with the new seed @@ -1405,7 +1369,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 + from deck_builder.random_entrypoint import build_random_full_deck # type: ignore res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1786,10 +1750,10 @@ async def hx_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed + from random_util import generate_seed # type: ignore 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 + from deck_builder.random_entrypoint import build_random_full_deck # type: ignore try: t0 = time.time() _attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS) @@ -1800,7 +1764,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 + from headless_runner import run as _run # type: ignore # Suppress builder's internal initial export to control artifact generation (matches full random path logic) try: import os as _os @@ -1813,18 +1777,18 @@ async def hx_random_reroll(request: Request): summary = None try: if hasattr(builder, 'build_deck_summary'): - summary = builder.build_deck_summary() + summary = builder.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None decklist = [] try: if hasattr(builder, 'deck_list_final'): - decklist = getattr(builder, 'deck_list_final') + decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined] except Exception: decklist = [] # Controlled artifact export (single pass) - csv_path = getattr(builder, 'last_csv_path', None) - txt_path = getattr(builder, 'last_txt_path', None) + csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] + txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] compliance = None try: import os as _os @@ -1832,7 +1796,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() + csv_path = builder.export_decklist_csv() # type: ignore[attr-defined] except Exception: csv_path = None if csv_path and isinstance(csv_path, str): @@ -1842,7 +1806,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) + txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined] except Exception: # Fallback: if a txt already exists from a prior build reuse it if _os.path.isfile(base_path + '.txt'): @@ -1857,7 +1821,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)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] except Exception: compliance = None if summary: @@ -2051,7 +2015,7 @@ async def hx_random_reroll(request: Request): except Exception: _permalink = None resp = templates.TemplateResponse( - "partials/random_result.html", + "partials/random_result.html", # type: ignore { "request": request, "seed": int(res.seed), @@ -2247,13 +2211,6 @@ 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 @@ -2266,8 +2223,6 @@ from .routes import partner_suggestions as partner_suggestions_routes # noqa: E 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) @@ -2279,8 +2234,6 @@ app.include_router(partner_suggestions_routes.router) 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: @@ -2467,7 +2420,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) + data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type] lines: list[str] if isinstance(data, JSONResponse): payload = data.body diff --git a/code/web/routes/api.py b/code/web/routes/api.py deleted file mode 100644 index 157344b..0000000 --- a/code/web/routes/api.py +++ /dev/null @@ -1,299 +0,0 @@ -"""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 c9c9090..9723ab6 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -14,7 +14,6 @@ from ..app import ( ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS, WEB_IDEALS_UI, - ENABLE_BATCH_BUILD, ) from ..services.build_utils import ( step5_base_ctx, @@ -25,12 +24,11 @@ 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 +from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore 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 @@ -119,7 +117,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: + def normalize_punctuation(x: str) -> str: # type: ignore return str(x).strip().casefold() norm_map: dict[str, str] = {} for name in names: @@ -470,7 +468,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", ()): + for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined] if not getattr(record, "is_background", False): continue name = getattr(record, "display_name", None) @@ -1108,8 +1106,6 @@ 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 @@ -1149,17 +1145,12 @@ 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") if should_auto_fill else None, + "commander": sess.get("commander"), "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, @@ -1357,19 +1348,6 @@ 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, @@ -1379,10 +1357,8 @@ async def build_new_modal(request: Request) -> HTMLResponse: "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "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"), @@ -1505,14 +1481,20 @@ 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 [] - deduplicated_recommended = [ - tag for tag in existing_recommended - if str(tag).strip().casefold() not in partner_tags_lower - ] - ctx["recommended"] = deduplicated_recommended + 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 reason_map = dict(ctx.get("recommended_reasons") or {}) for tag in partner_tags: @@ -1970,8 +1952,6 @@ async def build_new_submit( enforcement_mode: str = Form("warn"), allow_illegal: bool = Form(False), fuzzy_matching: bool = Form(True), - # Build count for multi-build - build_count: int = Form(1), # Quick Build flag quick_build: str | None = Form(None), ) -> HTMLResponse: @@ -2045,7 +2025,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(suggested), "tag_slot_html": None, } @@ -2070,7 +2049,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(commander), "tag_slot_html": None, } @@ -2175,7 +2153,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, } @@ -2314,7 +2291,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, } @@ -2503,101 +2479,7 @@ async def build_new_submit( # Centralized staged context creation sess["build_ctx"] = start_ctx_from_session(sess) - # Validate and normalize build_count - try: - build_count = max(1, min(10, int(build_count))) - except Exception: - build_count = 1 - - # Check if this is a multi-build request (build_count > 1) - if build_count > 1: - # Multi-Build: Queue parallel builds and return batch progress page - from ..services.multi_build_orchestrator import queue_builds, run_batch_async - - # Create config dict from session for batch builds - batch_config = { - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "tag_mode": sess.get("tag_mode", "AND"), - "bracket": sess.get("bracket", 3), - "ideals": sess.get("ideals", {}), - "prefer_combos": sess.get("prefer_combos", False), - "combo_target_count": sess.get("combo_target_count"), - "combo_balance": sess.get("combo_balance"), - "multi_copy": sess.get("multi_copy"), - "use_owned_only": sess.get("use_owned_only", False), - "prefer_owned": sess.get("prefer_owned", False), - "swap_mdfc_basics": sess.get("swap_mdfc_basics", False), - "include_cards": sess.get("include_cards", []), - "exclude_cards": sess.get("exclude_cards", []), - "enforcement_mode": sess.get("enforcement_mode", "warn"), - "allow_illegal": sess.get("allow_illegal", False), - "fuzzy_matching": sess.get("fuzzy_matching", True), - "locks": list(sess.get("locks", [])), - } - - # Handle partner mechanics if present - if sess.get("partner_enabled"): - batch_config["partner_enabled"] = True - if sess.get("secondary_commander"): - batch_config["secondary_commander"] = sess["secondary_commander"] - if sess.get("background"): - batch_config["background"] = sess["background"] - if sess.get("partner_mode"): - batch_config["partner_mode"] = sess["partner_mode"] - if sess.get("combined_commander"): - batch_config["combined_commander"] = sess["combined_commander"] - - # Add color identity for synergy builder (needed for basic land allocation) - try: - tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - - # Handle partner mechanics if present - if sess.get("partner_enabled") and sess.get("secondary_commander"): - from deck_builder.partner_selection import apply_partner_inputs - combined_obj = apply_partner_inputs( - tmp_builder, - primary_name=sess["commander"], - secondary_name=sess.get("secondary_commander"), - background_name=sess.get("background"), - feature_enabled=True, - ) - if combined_obj and hasattr(combined_obj, "color_identity"): - batch_config["colors"] = list(combined_obj.color_identity) - else: - # Single commander - df = tmp_builder.load_commander_data() - row = df[df["name"] == sess["commander"]] - if not row.empty: - # Get colorIdentity from dataframe (it's a string like "RG" or "G") - color_str = row.iloc[0].get("colorIdentity", "") - if color_str: - batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G'] - except Exception as e: - import logging - logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}") - pass # Not critical, synergy builder will skip basics if missing - - # Queue the batch - batch_id = queue_builds(batch_config, build_count, sid) - - # Start background task for parallel builds - background_tasks.add_task(run_batch_async, batch_id, sid) - - # Return batch progress template - progress_ctx = { - "request": request, - "batch_id": batch_id, - "build_count": build_count, - "completed": 0, - "current_build": 1, - "status": "Starting builds..." - } - resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Check if Quick Build was requested (single build only) + # Check if Quick Build was requested is_quick_build = (quick_build or "").strip() == "1" if is_quick_build: @@ -2865,7 +2747,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) + orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] ctx["idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1 except Exception: @@ -2923,11 +2805,6 @@ 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}, @@ -2961,22 +2838,7 @@ 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 @@ -3302,57 +3164,6 @@ 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", { @@ -3869,7 +3680,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"]) + orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list @@ -3931,16 +3742,6 @@ 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 @@ -3984,68 +3785,6 @@ def quick_build_progress(request: Request): response.set_cookie("sid", sid, httponly=True, samesite="lax") return response - -@router.get("/batch-progress") -def batch_build_progress(request: Request, batch_id: str = Query(...)): - """Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison.""" - import logging - logger = logging.getLogger(__name__) - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - from ..services.build_cache import BuildCache - - batch_status = BuildCache.get_batch_status(sess, batch_id) - logger.info(f"[Batch Progress Poll] batch_id={batch_id}, status={batch_status}") - - if not batch_status: - return HTMLResponse('
Batch not found. Please refresh.
') - - if batch_status["status"] == "completed": - # All builds complete - redirect to comparison page - response = HTMLResponse(f'') - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - # Get config to determine color count for time estimate - config = BuildCache.get_batch_config(sess, batch_id) - commander_name = config.get("commander", "") if config else "" - - # Estimate time based on color count (from testing data) - time_estimate = "1-3 minutes" - if commander_name and config: - # Try to get commander's color identity - try: - from ..services import orchestrator as orch - cmd_data = orch.load_commander(commander_name) - if cmd_data and "colorIdentity" in cmd_data: - color_count = len(cmd_data.get("colorIdentity", [])) - if color_count <= 2: - time_estimate = "1-3 minutes" - elif color_count == 3: - time_estimate = "2-4 minutes" - else: # 4-5 colors - time_estimate = "3-5 minutes" - except Exception: - pass # Default to 1-3 if we can't determine - - # Build still running - return progress content partial only - ctx = { - "request": request, - "batch_id": batch_id, - "build_count": batch_status["count"], - "completed": batch_status["completed"], - "progress_pct": batch_status["progress_pct"], - "status": f"Building deck {batch_status['completed'] + 1} of {batch_status['count']}..." if batch_status['completed'] < batch_status['count'] else "Finalizing...", - "has_errors": batch_status["has_errors"], - "error_count": batch_status["error_count"], - "time_estimate": time_estimate - } - response = templates.TemplateResponse("build/_batch_progress_content.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") @@ -4293,7 +4032,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 + import random as _rnd # type: ignore return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) except Exception: return list(seq[:limit]) @@ -4344,7 +4083,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) + return builder_display_map(b, lower_pool) # type: ignore[arg-type] except Exception: return {nm: nm for nm in lower_pool} @@ -4522,7 +4261,7 @@ async def build_alternatives( pass # Sort by priority like the builder try: - pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) + pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] except Exception: pass # Exclusions and ownership (for non-random roles this stays before slicing) @@ -5020,13 +4759,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) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass if not comp: @@ -5151,11 +4890,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() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] 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') + ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] except Exception: base_stem = None # Add lock placeholders into the library before enforcement so user choices are present @@ -5200,7 +4939,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') + rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] except Exception as e: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) @@ -5274,13 +5013,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) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass try: diff --git a/code/web/routes/compare.py b/code/web/routes/compare.py deleted file mode 100644 index 6dea835..0000000 --- a/code/web/routes/compare.py +++ /dev/null @@ -1,730 +0,0 @@ -""" -Comparison Routes - Side-by-side deck comparison for batch builds. -""" - -from __future__ import annotations -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse -from typing import Any, Dict, List -from ..app import templates -from ..services.build_cache import BuildCache -from ..services.tasks import get_session, new_sid -from ..services.synergy_builder import analyze_and_build_synergy_deck -from code.logging_util import get_logger -import time - -logger = get_logger(__name__) -router = APIRouter() - - -def _is_guaranteed_card(card_name: str) -> bool: - """ - Check if a card is guaranteed/staple (should be filtered from interesting variance). - - Filters: - - Basic lands (Plains, Island, Swamp, Mountain, Forest, Wastes, Snow-Covered variants) - - Staple lands (Command Tower, Reliquary Tower, etc.) - - Kindred lands - - Generic fetch lands - - Args: - card_name: Card name to check - - Returns: - True if card should be filtered from "Most Common Cards" - """ - try: - from code.deck_builder import builder_constants as bc - - # Basic lands - basic_lands = set(getattr(bc, 'BASIC_LANDS', [])) - if card_name in basic_lands: - return True - - # Snow-covered basics - if card_name.startswith('Snow-Covered '): - base_name = card_name.replace('Snow-Covered ', '') - if base_name in basic_lands: - return True - - # Staple lands (keys from STAPLE_LAND_CONDITIONS) - staple_conditions = getattr(bc, 'STAPLE_LAND_CONDITIONS', {}) - if card_name in staple_conditions: - return True - - # Kindred lands - kindred_lands = set(getattr(bc, 'KINDRED_LAND_NAMES', [])) - if card_name in kindred_lands: - return True - - # Generic fetch lands - generic_fetches = set(getattr(bc, 'GENERIC_FETCH_LANDS', [])) - if card_name in generic_fetches: - return True - - # Color-specific fetch lands - color_fetches = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) - for fetch_list in color_fetches.values(): - if card_name in fetch_list: - return True - - return False - except Exception as e: - logger.debug(f"Error checking guaranteed card status for {card_name}: {e}") - return False - - -@router.get("/compare/{batch_id}", response_class=HTMLResponse) -async def compare_batch(request: Request, batch_id: str) -> HTMLResponse: - """Main comparison view for batch builds.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return templates.TemplateResponse("error.html", { - "request": request, - "error": f"Batch {batch_id} not found. It may have expired.", - "back_link": "/build" - }) - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return templates.TemplateResponse("error.html", { - "request": request, - "error": "No completed builds found in this batch.", - "back_link": "/build" - }) - - # Calculate card overlap statistics - overlap_stats = _calculate_overlap(builds) - - # Prepare deck summaries - summaries = [] - for build in builds: - summary = _build_summary(build["result"], build["index"]) - summaries.append(summary) - - ctx = { - "request": request, - "batch_id": batch_id, - "batch_status": batch_status, - "config": config, - "builds": summaries, - "overlap_stats": overlap_stats, - "build_count": len(summaries), - "synergy_exported": BuildCache.is_synergy_exported(sess, batch_id) - } - - resp = templates.TemplateResponse("compare/index.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -def _calculate_overlap(builds: List[Dict[str, Any]]) -> Dict[str, Any]: - """ - Calculate card overlap statistics across builds. - - Args: - builds: List of build result dicts - - Returns: - Dict with overlap statistics - """ - from collections import Counter - - # Collect all cards with their appearance counts - card_counts: Counter = Counter() - total_builds = len(builds) - - # Collect include cards (must-includes) from first build as they should be in all - include_cards_set = set() - if builds: - first_result = builds[0].get("result", {}) - first_summary = first_result.get("summary", {}) - if isinstance(first_summary, dict): - include_exclude = first_summary.get("include_exclude_summary", {}) - if isinstance(include_exclude, dict): - includes = include_exclude.get("include_cards", []) - if isinstance(includes, list): - include_cards_set = set(includes) - - for build in builds: - result = build.get("result", {}) - summary = result.get("summary", {}) - if not isinstance(summary, dict): - continue - - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - continue - - # Track unique cards per build (from type_breakdown cards dict) - unique_cards = set() - type_cards = type_breakdown.get("cards", {}) - if isinstance(type_cards, dict): - for card_list in type_cards.values(): - if isinstance(card_list, list): - for card in card_list: - if isinstance(card, dict): - card_name = card.get("name") - if card_name: - unique_cards.add(card_name) - - # Increment counter for each unique card - for card_name in unique_cards: - card_counts[card_name] += 1 - - # Calculate statistics - total_unique_cards = len(card_counts) - cards_in_all = sum(1 for count in card_counts.values() if count == total_builds) - cards_in_most = sum(1 for count in card_counts.values() if count >= total_builds * 0.8) - cards_in_some = sum(1 for count in card_counts.values() if total_builds * 0.2 < count < total_builds * 0.8) - cards_in_few = sum(1 for count in card_counts.values() if count <= total_builds * 0.2) - - # Most common cards - filter out guaranteed/staple cards to highlight interesting variance - # Filter before taking top 20 to show random selections rather than guaranteed hits - filtered_counts = { - name: count for name, count in card_counts.items() - if not _is_guaranteed_card(name) and name not in include_cards_set - } - most_common = Counter(filtered_counts).most_common(20) - - return { - "total_unique_cards": total_unique_cards, - "cards_in_all": cards_in_all, - "cards_in_most": cards_in_most, - "cards_in_some": cards_in_some, - "cards_in_few": cards_in_few, - "most_common": most_common, - "total_builds": total_builds - } - - -def _build_summary(result: Dict[str, Any], index: int) -> Dict[str, Any]: - """ - Create a summary of a single build for comparison display. - - Args: - result: Build result from orchestrator - index: Build index - - Returns: - Summary dict - """ - # Get summary from result - summary = result.get("summary", {}) - if not isinstance(summary, dict): - summary = {} - - # Get type breakdown which contains card counts - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - type_breakdown = {} - - # Get counts directly from type breakdown - counts = type_breakdown.get("counts", {}) - - # Use standardized keys from type breakdown - creatures = counts.get("Creature", 0) - lands = counts.get("Land", 0) - artifacts = counts.get("Artifact", 0) - enchantments = counts.get("Enchantment", 0) - instants = counts.get("Instant", 0) - sorceries = counts.get("Sorcery", 0) - planeswalkers = counts.get("Planeswalker", 0) - - # Get total from type breakdown - total_cards = type_breakdown.get("total", 0) - - # Get all cards from type breakdown cards dict - all_cards = [] - type_cards = type_breakdown.get("cards", {}) - if isinstance(type_cards, dict): - for card_list in type_cards.values(): - if isinstance(card_list, list): - all_cards.extend(card_list) - - return { - "index": index, - "build_number": index + 1, - "total_cards": total_cards, - "creatures": creatures, - "lands": lands, - "artifacts": artifacts, - "enchantments": enchantments, - "instants": instants, - "sorceries": sorceries, - "planeswalkers": planeswalkers, - "cards": all_cards, - "result": result - } - - -@router.post("/compare/{batch_id}/export") -async def export_batch(request: Request, batch_id: str): - """ - Export all decks in a batch as a ZIP archive. - - Args: - request: FastAPI request object - batch_id: Batch identifier - - Returns: - ZIP file with all deck CSV/TXT files + summary JSON - """ - import zipfile - import io - import json - from pathlib import Path - from fastapi.responses import StreamingResponse - from datetime import datetime - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return {"error": f"Batch {batch_id} not found"} - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return {"error": "No completed builds found in this batch"} - - # Create ZIP in memory - zip_buffer = io.BytesIO() - - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: - # Collect all deck files - commander_name = config.get("commander", "Unknown").replace("/", "-") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - for i, build in enumerate(builds): - result = build.get("result", {}) - csv_path = result.get("csv_path") - txt_path = result.get("txt_path") - - # Add CSV file - if csv_path and Path(csv_path).exists(): - filename = f"Build_{i+1}_{commander_name}.csv" - with open(csv_path, 'rb') as f: - zip_file.writestr(filename, f.read()) - - # Add TXT file - if txt_path and Path(txt_path).exists(): - filename = f"Build_{i+1}_{commander_name}.txt" - with open(txt_path, 'rb') as f: - zip_file.writestr(filename, f.read()) - - # Add batch summary JSON - summary_data = { - "batch_id": batch_id, - "commander": config.get("commander"), - "themes": config.get("tags", []), - "bracket": config.get("bracket"), - "build_count": len(builds), - "exported_at": timestamp, - "builds": [ - { - "build_number": i + 1, - "csv_file": f"Build_{i+1}_{commander_name}.csv", - "txt_file": f"Build_{i+1}_{commander_name}.txt" - } - for i in range(len(builds)) - ] - } - zip_file.writestr("batch_summary.json", json.dumps(summary_data, indent=2)) - - # Prepare response - zip_buffer.seek(0) - zip_filename = f"{commander_name}_Batch_{timestamp}.zip" - - return StreamingResponse( - iter([zip_buffer.getvalue()]), - media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{zip_filename}"' - } - ) - - -@router.post("/compare/{batch_id}/rebuild") -async def rebuild_batch(request: Request, batch_id: str): - """ - Rebuild the same configuration with the same build count. - Creates a new batch with identical settings and redirects to batch progress. - - Args: - request: FastAPI request object - batch_id: Original batch identifier - - Returns: - Redirect to new batch progress page - """ - from fastapi.responses import RedirectResponse - from ..services.multi_build_orchestrator import MultiBuildOrchestrator - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get original config and build count - config = BuildCache.get_batch_config(sess, batch_id) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not config or not batch_status: - return RedirectResponse(url="/build", status_code=302) - - # Get build count from original batch - build_count = batch_status.get("total_builds", 1) - - # Create new batch with same config - orchestrator = MultiBuildOrchestrator() - new_batch_id = orchestrator.queue_builds(config, build_count, sid) - - # Start builds in background - import asyncio - asyncio.create_task(orchestrator.run_batch_parallel(new_batch_id)) - - # Redirect to new batch progress - response = RedirectResponse(url=f"/build/batch/{new_batch_id}/progress", status_code=302) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - -@router.post("/compare/{batch_id}/build-synergy") -async def build_synergy_deck(request: Request, batch_id: str) -> HTMLResponse: - """ - Build a synergy deck from batch builds. - - Analyzes all builds in the batch and creates an optimized "best-of" deck - by scoring cards based on frequency, EDHREC rank, and theme alignment. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not builds or not config or not batch_status: - return HTMLResponse( - content=f'
Batch {batch_id} not found or has no builds
', - status_code=404 - ) - - start_time = time.time() - - try: - # Analyze and build synergy deck - synergy_deck = analyze_and_build_synergy_deck(builds, config) - - elapsed_ms = int((time.time() - start_time) * 1000) - - logger.info( - f"[Synergy] Built deck for batch {batch_id}: " - f"{synergy_deck['total_cards']} cards, " - f"avg_score={synergy_deck['avg_score']}, " - f"elapsed={elapsed_ms}ms" - ) - - # Prepare cards_by_category for template - cards_by_category = { - category: [ - { - "name": card.name, - "frequency": card.frequency, - "synergy_score": card.synergy_score, - "appearance_count": card.appearance_count, - "role": card.role, - "tags": card.tags, - "type_line": card.type_line, - "count": card.count - } - for card in cards - ] - for category, cards in synergy_deck["by_category"].items() - } - - # Render preview template - return templates.TemplateResponse("compare/_synergy_preview.html", { - "request": request, - "batch_id": batch_id, - "synergy_deck": { - "total_cards": synergy_deck["total_cards"], - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"], - "cards_by_category": cards_by_category - }, - "total_builds": len(builds), - "build_time_ms": elapsed_ms - }) - - except Exception as e: - logger.error(f"[Synergy] Error building synergy deck: {e}", exc_info=True) - return HTMLResponse( - content=f'
Failed to build synergy deck: {str(e)}
', - status_code=500 - ) - - -@router.post("/compare/{batch_id}/export-synergy") -async def export_synergy_deck(request: Request, batch_id: str): - """ - Export the synergy deck as CSV and TXT files in a ZIP archive. - - Args: - request: FastAPI request object - batch_id: Batch identifier - - Returns: - ZIP file with synergy deck CSV/TXT files - """ - import io - import csv - import zipfile - import json - from fastapi.responses import StreamingResponse - from datetime import datetime - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return {"error": f"Batch {batch_id} not found"} - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return {"error": "No completed builds found in this batch"} - - # Build synergy deck (reuse the existing logic) - from code.web.services.synergy_builder import analyze_and_build_synergy_deck - - try: - synergy_deck = analyze_and_build_synergy_deck( - builds=builds, - config=config - ) - except Exception as e: - logger.error(f"[Export Synergy] Error building synergy deck: {e}", exc_info=True) - return {"error": f"Failed to build synergy deck: {str(e)}"} - - # Prepare file names - commander_name = config.get("commander", "Unknown").replace("/", "-").replace(" ", "") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_filename = f"{commander_name}_Synergy_{timestamp}" - - # Prepare deck_files directory - from pathlib import Path - deck_files_dir = Path("deck_files") - deck_files_dir.mkdir(parents=True, exist_ok=True) - - # Create CSV content - csv_buffer = io.StringIO() - csv_writer = csv.writer(csv_buffer) - - # CSV Header - csv_writer.writerow([ - "Name", "Count", "Category", "Role", "Frequency", "Synergy Score", - "Appearance Count", "Tags", "Type" - ]) - - # CSV Rows - sort by category - category_order = ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"] - by_category = synergy_deck.get("by_category", {}) - - for category in category_order: - cards = by_category.get(category, []) - for card in cards: - csv_writer.writerow([ - card.name, - card.count, - card.category, - card.role, - f"{card.frequency:.2%}", - f"{card.synergy_score:.2f}", - card.appearance_count, - "|".join(card.tags) if card.tags else "", - card.type_line - ]) - - csv_content = csv_buffer.getvalue() - - # Create TXT content (Moxfield/EDHREC format) - txt_buffer = io.StringIO() - - # TXT Header - txt_buffer.write(f"# Synergy Deck - {commander_name}\n") - txt_buffer.write(f"# Commander: {config.get('commander', 'Unknown')}\n") - txt_buffer.write(f"# Colors: {', '.join(config.get('colors', []))}\n") - txt_buffer.write(f"# Themes: {', '.join(config.get('tags', []))}\n") - txt_buffer.write(f"# Generated from {len(builds)} builds\n") - txt_buffer.write(f"# Total Cards: {synergy_deck['total_cards']}\n") - txt_buffer.write(f"# Avg Frequency: {synergy_deck['avg_frequency']:.1%}\n") - txt_buffer.write(f"# Avg Synergy Score: {synergy_deck['avg_score']:.2f}\n") - txt_buffer.write("\n") - - # TXT Card list - for category in category_order: - cards = by_category.get(category, []) - if not cards: - continue - - for card in cards: - line = f"{card.count} {card.name}" - if card.count > 1: - # Show count prominently for multi-copy cards - txt_buffer.write(f"{line}\n") - else: - txt_buffer.write(f"1 {card.name}\n") - - txt_content = txt_buffer.getvalue() - - # Save CSV and TXT to deck_files directory - csv_path = deck_files_dir / f"{base_filename}.csv" - txt_path = deck_files_dir / f"{base_filename}.txt" - summary_path = deck_files_dir / f"{base_filename}.summary.json" - compliance_path = deck_files_dir / f"{base_filename}_compliance.json" - - try: - csv_path.write_text(csv_content, encoding='utf-8') - txt_path.write_text(txt_content, encoding='utf-8') - - # Create summary JSON (similar to individual builds) - summary_data = { - "commander": config.get("commander", "Unknown"), - "tags": config.get("tags", []), - "colors": config.get("colors", []), - "bracket_level": config.get("bracket"), - "csv": str(csv_path), - "txt": str(txt_path), - "synergy_stats": { - "total_cards": synergy_deck["total_cards"], - "unique_cards": synergy_deck.get("unique_cards", len(synergy_deck["cards"])), - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"], - "source_builds": len(builds) - }, - "exported_at": timestamp - } - summary_path.write_text(json.dumps(summary_data, indent=2), encoding='utf-8') - - # Create compliance JSON (basic compliance for synergy deck) - compliance_data = { - "overall": "N/A", - "message": "Synergy deck - compliance checking not applicable", - "deck_size": synergy_deck["total_cards"], - "commander": config.get("commander", "Unknown"), - "source": "synergy_builder", - "build_count": len(builds) - } - compliance_path.write_text(json.dumps(compliance_data, indent=2), encoding='utf-8') - - logger.info(f"[Export Synergy] Saved synergy deck to {csv_path} and {txt_path}") - except Exception as e: - logger.error(f"[Export Synergy] Failed to save files to disk: {e}", exc_info=True) - - # Delete batch build files to avoid clutter - deleted_files = [] - for build in builds: - result = build.get("result", {}) - csv_file = result.get("csv_path") - txt_file = result.get("txt_path") - summary_file = result.get("summary_path") - - # Delete CSV file - if csv_file: - csv_p = Path(csv_file) - if csv_p.exists(): - try: - csv_p.unlink() - deleted_files.append(csv_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {csv_file}: {e}") - - # Delete TXT file - if txt_file: - txt_p = Path(txt_file) - if txt_p.exists(): - try: - txt_p.unlink() - deleted_files.append(txt_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {txt_file}: {e}") - - # Delete summary JSON file - if summary_file: - summary_p = Path(summary_file) - if summary_p.exists(): - try: - summary_p.unlink() - deleted_files.append(summary_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {summary_file}: {e}") - - if deleted_files: - logger.info(f"[Export Synergy] Cleaned up {len(deleted_files)} batch build files") - - # Mark batch as having synergy exported (to disable batch export button) - BuildCache.mark_synergy_exported(sess, batch_id) - - # Create ZIP in memory for download - zip_buffer = io.BytesIO() - - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: - # Add CSV to ZIP - zip_file.writestr(f"{base_filename}.csv", csv_content) - - # Add TXT to ZIP - zip_file.writestr(f"{base_filename}.txt", txt_content) - - # Add summary JSON to ZIP - summary_json = json.dumps(summary_data, indent=2) - zip_file.writestr(f"{base_filename}.summary.json", summary_json) - - # Add compliance JSON to ZIP - compliance_json = json.dumps(compliance_data, indent=2) - zip_file.writestr(f"{base_filename}_compliance.json", compliance_json) - - # Add metadata JSON (export-specific info) - metadata = { - "batch_id": batch_id, - "commander": config.get("commander"), - "themes": config.get("tags", []), - "colors": config.get("colors", []), - "bracket": config.get("bracket"), - "build_count": len(builds), - "exported_at": timestamp, - "synergy_stats": { - "total_cards": synergy_deck["total_cards"], - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"] - }, - "cleaned_up_files": len(deleted_files) - } - zip_file.writestr("synergy_metadata.json", json.dumps(metadata, indent=2)) - - # Prepare response - zip_buffer.seek(0) - zip_filename = f"{base_filename}.zip" - - return StreamingResponse( - iter([zip_buffer.getvalue()]), - media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{zip_filename}"' - } - ) diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 9b4f290..957936b 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}) + options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type] diffs = None metaA: Dict[str, str] = {} diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index dc711d4..9cbe635 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 +from ..services.orchestrator import _ensure_setup_ready # type: ignore 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) + _ensure_setup_ready(print, force=force) # type: ignore[arg-type] 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: +async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override] # Optionally start the setup/tagging in the background if requested try: if start and int(start) != 0: @@ -195,11 +195,7 @@ 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, - "image_cache_enabled": image_cache.is_enabled() + "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES }) diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py index 4917aa7..32cb279 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 +from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore 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 -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 +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 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 + from ..app import templates as _templates # type: ignore 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 + from ..app import rate_limit_check # type: ignore 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"]) + yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type] except Exception: yaml_file_count = -1 tagged_time = _load_tag_flag_time() @@ -291,6 +291,28 @@ 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(): @@ -547,7 +569,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) + text = _yaml.safe_dump(y, sort_keys=False) # type: ignore headers = {"Content-Type": "text/plain; charset=utf-8"} return HTMLResponse(text, headers=headers) @@ -631,7 +653,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) + themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined] # Phase 1 + 2: exact / prefix for t in themes_iter: name = t.theme @@ -724,9 +746,89 @@ 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. - -@router.get("/fragment/list", response_class=HTMLResponse) + 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 # --- Preview Export Endpoints (CSV / JSON) --- diff --git a/code/web/services/build_cache.py b/code/web/services/build_cache.py deleted file mode 100644 index 1511cba..0000000 --- a/code/web/services/build_cache.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Build Cache - Session-based storage for multi-build batch results. - -Stores completed deck builds in session for comparison view. -""" - -from __future__ import annotations -from typing import Any, Dict, List, Optional -import time -import uuid - - -class BuildCache: - """Manages storage and retrieval of batch build results in session.""" - - @staticmethod - def create_batch(sess: Dict[str, Any], config: Dict[str, Any], count: int) -> str: - """ - Create a new batch build entry in session. - - Args: - sess: Session dictionary - config: Deck configuration (commander, themes, ideals, etc.) - count: Number of builds in batch - - Returns: - batch_id: Unique identifier for this batch - """ - batch_id = f"batch_{uuid.uuid4().hex[:12]}" - - if "batch_builds" not in sess: - sess["batch_builds"] = {} - - sess["batch_builds"][batch_id] = { - "batch_id": batch_id, - "config": config, - "count": count, - "completed": 0, - "builds": [], - "started_at": time.time(), - "completed_at": None, - "status": "running", # running, completed, error - "errors": [] - } - - return batch_id - - @staticmethod - def store_build(sess: Dict[str, Any], batch_id: str, build_index: int, result: Dict[str, Any]) -> None: - """ - Store a completed build result in the batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - build_index: Index of this build (0-based) - result: Deck build result from orchestrator - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - raise ValueError(f"Batch {batch_id} not found in session") - - batch = sess["batch_builds"][batch_id] - - # Ensure builds list has enough slots - while len(batch["builds"]) <= build_index: - batch["builds"].append(None) - - # Store build result with minimal data for comparison - batch["builds"][build_index] = { - "index": build_index, - "result": result, - "completed_at": time.time() - } - - batch["completed"] += 1 - - # Mark batch as completed if all builds done - if batch["completed"] >= batch["count"]: - batch["status"] = "completed" - batch["completed_at"] = time.time() - - @staticmethod - def store_build_error(sess: Dict[str, Any], batch_id: str, build_index: int, error: str) -> None: - """ - Store an error for a failed build. - - Args: - sess: Session dictionary - batch_id: Batch identifier - build_index: Index of this build (0-based) - error: Error message - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - raise ValueError(f"Batch {batch_id} not found in session") - - batch = sess["batch_builds"][batch_id] - - batch["errors"].append({ - "build_index": build_index, - "error": error, - "timestamp": time.time() - }) - - batch["completed"] += 1 - - # Mark batch as completed if all builds done (even with errors) - if batch["completed"] >= batch["count"]: - batch["status"] = "completed" if not batch["errors"] else "error" - batch["completed_at"] = time.time() - - @staticmethod - def get_batch_status(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: - """ - Get current status of a batch build. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - Status dict with progress info, or None if not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - batch = sess["batch_builds"][batch_id] - - return { - "batch_id": batch_id, - "status": batch["status"], - "count": batch["count"], - "completed": batch["completed"], - "progress_pct": int((batch["completed"] / batch["count"]) * 100) if batch["count"] > 0 else 0, - "has_errors": len(batch["errors"]) > 0, - "error_count": len(batch["errors"]), - "elapsed_time": time.time() - batch["started_at"] - } - - @staticmethod - def get_batch_builds(sess: Dict[str, Any], batch_id: str) -> Optional[List[Dict[str, Any]]]: - """ - Get all completed builds for a batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - List of build results, or None if batch not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - batch = sess["batch_builds"][batch_id] - return [b for b in batch["builds"] if b is not None] - - @staticmethod - def get_batch_config(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: - """ - Get the original configuration for a batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - Config dict, or None if batch not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - return sess["batch_builds"][batch_id]["config"] - - @staticmethod - def clear_batch(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Remove a batch from session. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if batch was found and removed, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - del sess["batch_builds"][batch_id] - return True - - @staticmethod - def list_batches(sess: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - List all batches in session with summary info. - - Args: - sess: Session dictionary - - Returns: - List of batch summary dicts - """ - if "batch_builds" not in sess: - return [] - - summaries = [] - for batch_id, batch in sess["batch_builds"].items(): - summaries.append({ - "batch_id": batch_id, - "status": batch["status"], - "count": batch["count"], - "completed": batch["completed"], - "commander": batch["config"].get("commander", "Unknown"), - "started_at": batch["started_at"], - "completed_at": batch.get("completed_at") - }) - - # Sort by start time, most recent first - summaries.sort(key=lambda x: x["started_at"], reverse=True) - return summaries - - @staticmethod - def mark_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Mark a batch as having its synergy deck exported (disables batch export). - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if batch was found and marked, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - sess["batch_builds"][batch_id]["synergy_exported"] = True - sess["batch_builds"][batch_id]["synergy_exported_at"] = time.time() - return True - - @staticmethod - def is_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Check if a batch's synergy deck has been exported. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if synergy has been exported, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - return sess["batch_builds"][batch_id].get("synergy_exported", False) diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 8c11c56..a37a540 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: + def format_theme_label(value: Any) -> str: # type: ignore[redef] 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]: + def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef] seen: set[str] = set() result: list[str] = [] - for raw in values or []: + for raw in values or []: # type: ignore[arg-type] label = format_theme_label(raw) if not label or len(label) <= 1: continue @@ -310,30 +310,13 @@ 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) - # 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 + # M7: For non-partner commanders, also check summary.colors for 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)): @@ -420,7 +403,7 @@ def step5_ctx_from_result( else: entry = {} try: - entry.update(vars(item)) + entry.update(vars(item)) # type: ignore[arg-type] except Exception: pass # Preserve common attributes when vars() empty diff --git a/code/web/services/multi_build_orchestrator.py b/code/web/services/multi_build_orchestrator.py deleted file mode 100644 index 65fcf1b..0000000 --- a/code/web/services/multi_build_orchestrator.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Multi-Build Orchestrator - Parallel execution of identical deck builds. - -Runs the same deck configuration N times in parallel to analyze variance. -""" - -from __future__ import annotations -from typing import Any, Dict -from concurrent.futures import ThreadPoolExecutor -from .build_cache import BuildCache -from .tasks import get_session -from ..services import orchestrator as orch -from code.logging_util import get_logger - -logger = get_logger(__name__) - - -class MultiBuildOrchestrator: - """Manages parallel execution of multiple identical deck builds.""" - - def __init__(self, max_parallel: int = 5): - """ - Initialize orchestrator. - - Args: - max_parallel: Maximum number of builds to run concurrently (default 5) - """ - self.max_parallel = max_parallel - - def run_batch_parallel(self, batch_id: str, sid: str) -> None: - """ - Run a batch of builds in parallel (blocking call). - - This should be called from a background task. - - Args: - batch_id: Batch identifier - sid: Session ID - """ - logger.info(f"[Multi-Build] Starting parallel batch {batch_id} for session {sid}") - - sess = get_session(sid) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not batch_status: - logger.error(f"[Multi-Build] Batch {batch_id} not found in session") - return - - count = batch_status["count"] - config = BuildCache.get_batch_config(sess, batch_id) - - if not config: - logger.error(f"[Multi-Build] Config not found for batch {batch_id}") - return - - logger.info(f"[Multi-Build] Running {count} builds in parallel (max {self.max_parallel} concurrent)") - - # Use ThreadPoolExecutor for parallel execution - # Each build runs in its own thread to avoid blocking - with ThreadPoolExecutor(max_workers=min(count, self.max_parallel)) as executor: - futures = [] - - for i in range(count): - future = executor.submit(self._run_single_build, batch_id, i, config, sid) - futures.append(future) - - # Wait for all builds to complete - for i, future in enumerate(futures): - try: - future.result() # This will raise if the build failed - logger.info(f"[Multi-Build] Build {i+1}/{count} completed successfully") - except Exception as e: - logger.error(f"[Multi-Build] Build {i+1}/{count} failed: {e}") - # Error already stored in _run_single_build - - logger.info(f"[Multi-Build] Batch {batch_id} completed") - - def _run_single_build(self, batch_id: str, build_index: int, config: Dict[str, Any], sid: str) -> None: - """ - Run a single build and store the result. - - Args: - batch_id: Batch identifier - build_index: Index of this build (0-based) - config: Deck configuration - sid: Session ID - """ - try: - logger.info(f"[Multi-Build] Build {build_index}: Starting for batch {batch_id}") - - # Get a fresh session reference for this thread - sess = get_session(sid) - - logger.debug(f"[Multi-Build] Build {build_index}: Creating build context") - - # Create a temporary build context for this specific build - # We need to ensure each build has isolated state - build_ctx = self._create_build_context(config, sess, build_index) - - logger.debug(f"[Multi-Build] Build {build_index}: Running all stages") - - # Run all stages to completion - result = self._run_all_stages(build_ctx, build_index) - - logger.debug(f"[Multi-Build] Build {build_index}: Storing result") - - # Store the result - BuildCache.store_build(sess, batch_id, build_index, result) - - logger.info(f"[Multi-Build] Build {build_index}: Completed, stored in batch {batch_id}") - - except Exception as e: - logger.exception(f"[Multi-Build] Build {build_index}: Error - {e}") - sess = get_session(sid) - BuildCache.store_build_error(sess, batch_id, build_index, str(e)) - - def _create_build_context(self, config: Dict[str, Any], sess: Dict[str, Any], build_index: int) -> Dict[str, Any]: - """ - Create a build context from configuration. - - Args: - config: Deck configuration - sess: Session dictionary - build_index: Index of this build - - Returns: - Build context dict ready for orchestrator - """ - # Import here to avoid circular dependencies - from .build_utils import start_ctx_from_session - - # Create a temporary session-like dict with the config - temp_sess = { - "commander": config.get("commander"), - "tags": config.get("tags", []), - "tag_mode": config.get("tag_mode", "AND"), - "bracket": config.get("bracket", 3), - "ideals": config.get("ideals", {}), - "prefer_combos": config.get("prefer_combos", False), - "combo_target_count": config.get("combo_target_count"), - "combo_balance": config.get("combo_balance"), - "multi_copy": config.get("multi_copy"), - "use_owned_only": config.get("use_owned_only", False), - "prefer_owned": config.get("prefer_owned", False), - "swap_mdfc_basics": config.get("swap_mdfc_basics", False), - "include_cards": config.get("include_cards", []), - "exclude_cards": config.get("exclude_cards", []), - "enforcement_mode": config.get("enforcement_mode", "warn"), - "allow_illegal": config.get("allow_illegal", False), - "fuzzy_matching": config.get("fuzzy_matching", True), - "locks": set(config.get("locks", [])), - "replace_mode": True, - # Add build index to context for debugging - "batch_build_index": build_index - } - - # Handle partner mechanics if present - if config.get("partner_enabled"): - temp_sess["partner_enabled"] = True - if config.get("secondary_commander"): - temp_sess["secondary_commander"] = config["secondary_commander"] - if config.get("background"): - temp_sess["background"] = config["background"] - if config.get("partner_mode"): - temp_sess["partner_mode"] = config["partner_mode"] - if config.get("combined_commander"): - temp_sess["combined_commander"] = config["combined_commander"] - - # Generate build context using existing utility - ctx = start_ctx_from_session(temp_sess) - - return ctx - - def _run_all_stages(self, ctx: Dict[str, Any], build_index: int = 0) -> Dict[str, Any]: - """ - Run all build stages to completion. - - Args: - ctx: Build context - build_index: Index of this build for logging - - Returns: - Final result dict from orchestrator - """ - stages = ctx.get("stages", []) - result = None - - logger.debug(f"[Multi-Build] Build {build_index}: Starting stage loop ({len(stages)} stages)") - - iteration = 0 - max_iterations = 100 # Safety limit to prevent infinite loops - - while iteration < max_iterations: - current_idx = ctx.get("idx", 0) - if current_idx >= len(stages): - logger.debug(f"[Multi-Build] Build {build_index}: All stages completed (idx={current_idx}/{len(stages)})") - break - - stage_name = stages[current_idx].get("name", f"Stage {current_idx}") if current_idx < len(stages) else "Unknown" - logger.debug(f"[Multi-Build] Build {build_index}: Running stage {current_idx}/{len(stages)}: {stage_name}") - - # Run stage with show_skipped=False for clean output - result = orch.run_stage(ctx, rerun=False, show_skipped=False) - - # Check if build is done - if result.get("done"): - logger.debug(f"[Multi-Build] Build {build_index}: Build marked as done after stage {stage_name}") - break - - iteration += 1 - - if iteration >= max_iterations: - logger.warning(f"[Multi-Build] Build {build_index}: Hit max iterations ({max_iterations}), possible infinite loop. Last stage: {stage_name}") - - logger.debug(f"[Multi-Build] Build {build_index}: Stage loop completed after {iteration} iterations") - return result or {} - - -# Global orchestrator instance -_orchestrator = MultiBuildOrchestrator(max_parallel=5) - - -def queue_builds(config: Dict[str, Any], count: int, sid: str) -> str: - """ - Queue a batch of builds for parallel execution. - - Args: - config: Deck configuration - count: Number of builds to run - sid: Session ID - - Returns: - batch_id: Unique identifier for this batch - """ - sess = get_session(sid) - batch_id = BuildCache.create_batch(sess, config, count) - return batch_id - - -def run_batch_async(batch_id: str, sid: str) -> None: - """ - Run a batch of builds in parallel (blocking call for background task). - - Args: - batch_id: Batch identifier - sid: Session ID - """ - _orchestrator.run_batch_parallel(batch_id, sid) - - -def get_batch_status(batch_id: str, sid: str) -> Dict[str, Any]: - """ - Get current status of a batch build. - - Args: - batch_id: Batch identifier - sid: Session ID - - Returns: - Status dict with progress info - """ - sess = get_session(sid) - status = BuildCache.get_batch_status(sess, batch_id) - return status or {"error": "Batch not found"} diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 654d5ac..c38b78d 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 + import pandas as _pd # type: ignore 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) + query = tmp._normalize_commander_query(query) # type: ignore[attr-defined] 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) + name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined] 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 - from .theme_preview import bust_preview_cache + from .theme_catalog_loader import bust_filter_cache # type: ignore + from .theme_preview import bust_preview_cache # type: ignore 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 + from path_util import get_processed_cards_path # type: ignore 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 + from file_setup.setup import initial_setup # type: ignore # 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 + from tagging import tagger as _tagger # type: ignore 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 + from file_setup.card_aggregator import CardAggregator # type: ignore 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 + import pandas as pd # type: ignore # 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 - from .theme_preview import bust_preview_cache + from .theme_catalog_loader import bust_filter_cache # type: ignore + from .theme_preview import bust_preview_cache # type: ignore 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 + b.use_owned_only = True # type: ignore[attr-defined] # 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()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: - b.owned_card_names = set() + b.owned_card_names = set() # type: ignore[attr-defined] # Soft preference flag does not filter; only biases selection order if prefer_owned: try: - b.prefer_owned = True + b.prefer_owned = True # type: ignore[attr-defined] 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()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] 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) + b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined] if combo_target_count is not None: - b.combo_target_count = int(combo_target_count) + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal + b.combo_balance = bal # type: ignore[attr-defined] 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() + csv_path = b.export_decklist_csv() # type: ignore[attr-defined] 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') + txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] 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) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] # 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') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] 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() + summary = b.build_deck_summary() # type: ignore[attr-defined] 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() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] 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 + builder.combined_commander = combined # type: ignore[attr-defined] except Exception: pass try: - builder.partner_mode = getattr(combined, "partner_mode", None) + builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined] except Exception: pass try: - builder.secondary_commander = getattr(combined, "secondary_name", None) + builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined] except Exception: pass try: - 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) + 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] 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 + b.use_owned_only = True # type: ignore[attr-defined] if owned_names: try: - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: - b.owned_card_names = set() + b.owned_card_names = set() # type: ignore[attr-defined] if prefer_owned: try: - b.prefer_owned = True + b.prefer_owned = True # type: ignore[attr-defined] 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()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] 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) + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] except Exception: pass try: if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal + b.combo_balance = bal # type: ignore[attr-defined] 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() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] 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') + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') + b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] 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) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] 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') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] 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() + summary = b.build_deck_summary() # type: ignore[attr-defined] 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() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] 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) + comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp_now = None try: if comp_now: - comp_now = _attach_enforcement_plan(b, comp_now) + comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined] 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) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] 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) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] 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) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] 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() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] 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') + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') + b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] 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) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] 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') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] 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() + summary = b.build_deck_summary() # type: ignore[attr-defined] 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() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] 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 b781ef5..91eb97e 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 + from .orchestrator import _maybe_refresh_partner_synergy # type: ignore _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 b93a688..2f2b368 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 +from .preview_metrics import record_eviction # type: ignore # 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 +from .preview_cache_backend import redis_store # type: ignore 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 d24d635..3750d22 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 + import redis # type: ignore except Exception: # pragma: no cover - absence path - redis = None + redis = None # type: ignore _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) + _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore # 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) + raw: bytes | None = _CLIENT.get(skey) # type: ignore 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 40d8a0b..f7e9aad 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 + c["_splash_off_color"] = True # type: ignore 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 4bb10eb..aee1a3f 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 + from .build_utils import owned_set as _owned_set # type: ignore 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 []: + for raw in values or []: # type: ignore[arg-type] 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 []: + for raw in values or []: # type: ignore[arg-type] label = format_theme_label(raw) if not label: continue diff --git a/code/web/services/synergy_builder.py b/code/web/services/synergy_builder.py deleted file mode 100644 index 3bd49c9..0000000 --- a/code/web/services/synergy_builder.py +++ /dev/null @@ -1,607 +0,0 @@ -""" -Synergy Builder - Analyzes multiple deck builds and creates optimized "best-of" deck. - -Takes multiple builds of the same configuration and identifies cards that appear -frequently across builds, scoring them for synergy based on: -- Frequency of appearance (higher = more consistent with strategy) -- EDHREC rank (lower rank = more popular/powerful) -- Theme tag matches (more matching tags = better fit) -""" - -from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional -from collections import Counter -from code.logging_util import get_logger -from code.deck_builder import builder_utils as bu -import pandas as pd -import os - -logger = get_logger(__name__) - - -@dataclass -class ScoredCard: - """A card with its synergy score and metadata.""" - name: str - frequency: float # 0.0-1.0, percentage of builds containing this card - appearance_count: int # Number of builds this card appears in - synergy_score: float # 0-100+ calculated score - category: str # Card type category (Creature, Land, etc.) - role: str = "" # Card role from tagging - tags: List[str] = field(default_factory=list) # Theme tags - edhrec_rank: Optional[int] = None # EDHREC rank if available - count: int = 1 # Number of copies (usually 1 for Commander) - type_line: str = "" # Full type line (e.g., "Creature — Rabbit Scout") - - -@dataclass -class CardPool: - """Aggregated pool of cards from multiple builds.""" - cards: Dict[str, ScoredCard] # card_name -> ScoredCard - total_builds: int - config: Dict[str, Any] # Original build configuration - themes: List[str] # Theme tags from config - - def get_by_category(self, category: str) -> List[ScoredCard]: - """Get all cards in a specific category.""" - return [card for card in self.cards.values() if card.category == category] - - def get_top_cards(self, limit: int = 100) -> List[ScoredCard]: - """Get top N cards by synergy score.""" - return sorted(self.cards.values(), key=lambda c: c.synergy_score, reverse=True)[:limit] - - def get_high_frequency_cards(self, min_frequency: float = 0.8) -> List[ScoredCard]: - """Get cards appearing in at least min_frequency of builds.""" - return [card for card in self.cards.values() if card.frequency >= min_frequency] - - -class SynergyAnalyzer: - """Analyzes multiple builds and scores cards for synergy.""" - - # Scoring weights - FREQUENCY_WEIGHT = 0.5 - EDHREC_WEIGHT = 0.25 - THEME_WEIGHT = 0.25 - HIGH_FREQUENCY_BONUS = 1.1 # 10% bonus for cards in 80%+ builds - - def __init__(self): - """Initialize synergy analyzer.""" - self._type_line_cache: Dict[str, str] = {} - - def _load_type_lines(self) -> Dict[str, str]: - """ - Load card type lines from parquet for all cards. - - Returns: - Dict mapping card name (lowercase) to type_line - """ - if self._type_line_cache: - return self._type_line_cache - - try: - parquet_path = os.path.join("card_files", "processed", "all_cards.parquet") - if not os.path.exists(parquet_path): - logger.warning(f"[Synergy] Card parquet not found at {parquet_path}") - return {} - - df = pd.read_parquet(parquet_path) - - # Try 'type' first, then 'type_line' - type_col = None - if 'type' in df.columns: - type_col = 'type' - elif 'type_line' in df.columns: - type_col = 'type_line' - - if not type_col or 'name' not in df.columns: - logger.warning(f"[Synergy] Card parquet missing required columns. Available: {list(df.columns)}") - return {} - - # Build mapping: lowercase name -> type_line - for _, row in df.iterrows(): - name = str(row.get('name', '')).strip() - type_line = str(row.get(type_col, '')).strip() - if name and type_line: - self._type_line_cache[name.lower()] = type_line - - logger.info(f"[Synergy] Loaded type lines for {len(self._type_line_cache)} cards from parquet") - return self._type_line_cache - - except Exception as e: - logger.warning(f"[Synergy] Error loading type lines from parquet: {e}") - return {} - - def analyze_builds(self, builds: List[Dict[str, Any]], config: Dict[str, Any]) -> CardPool: - """ - Aggregate all cards from builds and calculate appearance frequencies. - - Args: - builds: List of build results from BuildCache - config: Original deck configuration - - Returns: - CardPool with all unique cards and their frequencies - """ - logger.info(f"[Synergy] Analyzing {len(builds)} builds for synergy") - - if not builds: - raise ValueError("Cannot analyze synergy with no builds") - - total_builds = len(builds) - themes = config.get("tags", []) - - # Load type lines from card CSV - type_line_map = self._load_type_lines() - - # Count card appearances and cumulative counts across all builds - card_appearances: Counter = Counter() # card_name -> number of builds containing it - card_total_counts: Counter = Counter() # card_name -> sum of counts across all builds - card_metadata: Dict[str, Dict[str, Any]] = {} - - for build in builds: - result = build.get("result", {}) - summary = result.get("summary", {}) - - if not isinstance(summary, dict): - logger.warning("[Synergy] Build missing summary, skipping") - continue - - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - continue - - type_cards = type_breakdown.get("cards", {}) - if not isinstance(type_cards, dict): - continue - - # Collect unique cards from this build - unique_cards_in_build = set() - - for category, card_list in type_cards.items(): - if not isinstance(card_list, list): - continue - - for card in card_list: - if not isinstance(card, dict): - continue - - card_name = card.get("name") - if not card_name: - continue - - card_count = card.get("count", 1) - unique_cards_in_build.add(card_name) - - # Track cumulative count across all builds (for multi-copy cards like basics) - card_total_counts[card_name] += card_count - - # Store metadata (first occurrence) - if card_name not in card_metadata: - # Get type_line from parquet, fallback to card data (which won't have it from summary) - type_line = type_line_map.get(card_name.lower(), "") - if not type_line: - type_line = card.get("type", card.get("type_line", "")) - - # Debug: Log first few cards - if len(card_metadata) < 3: - logger.info(f"[Synergy Debug] Card: {card_name}, Type line: {type_line}, From map: {card_name.lower() in type_line_map}") - - card_metadata[card_name] = { - "category": category, - "role": card.get("role", ""), - "tags": card.get("tags", []), - "type_line": type_line - } - - # Increment appearance count for each unique card in this build - for card_name in unique_cards_in_build: - card_appearances[card_name] += 1 - - # Create ScoredCard objects with frequencies and average counts - scored_cards: Dict[str, ScoredCard] = {} - - for card_name, appearance_count in card_appearances.items(): - frequency = appearance_count / total_builds - metadata = card_metadata.get(card_name, {}) - - scored_card = ScoredCard( - name=card_name, - frequency=frequency, - appearance_count=appearance_count, - synergy_score=0.0, # Will be calculated next - category=metadata.get("category", "Unknown"), - role=metadata.get("role", ""), - tags=metadata.get("tags", []), - count=1, # Default to 1 copy per card in synergy deck (basics override this later) - type_line=metadata.get("type_line", "") - ) - - # Debug: Log first few scored cards - if len(scored_cards) < 3: - logger.info(f"[Synergy Debug] ScoredCard: {scored_card.name}, type_line='{scored_card.type_line}', count={scored_card.count}, in_map={card_name.lower() in type_line_map}") - - # Calculate synergy score - scored_card.synergy_score = self.score_card(scored_card, themes) - - scored_cards[card_name] = scored_card - - logger.info(f"[Synergy] Analyzed {len(scored_cards)} unique cards from {total_builds} builds") - - return CardPool( - cards=scored_cards, - total_builds=total_builds, - config=config, - themes=themes - ) - - def score_card(self, card: ScoredCard, themes: List[str]) -> float: - """ - Calculate synergy score for a card. - - Score = frequency_weight * frequency * 100 + - edhrec_weight * (1 - rank/max_rank) * 100 + - theme_weight * (matching_tags / total_tags) * 100 - - Args: - card: ScoredCard to score - themes: Theme tags from config - - Returns: - Synergy score (0-100+) - """ - # Frequency component (0-100) - frequency_score = card.frequency * 100 - - # EDHREC component (placeholder - would need EDHREC data) - # For now, assume no EDHREC data available - edhrec_score = 50.0 # Neutral score - - # Theme component (0-100) - theme_score = 0.0 - if themes and card.tags: - theme_set = set(themes) - card_tag_set = set(card.tags) - matching_tags = len(theme_set & card_tag_set) - theme_score = (matching_tags / len(themes)) * 100 if themes else 0.0 - - # Calculate weighted score - score = ( - self.FREQUENCY_WEIGHT * frequency_score + - self.EDHREC_WEIGHT * edhrec_score + - self.THEME_WEIGHT * theme_score - ) - - # Bonus for high-frequency cards (appear in 80%+ builds) - if card.frequency >= 0.8: - score *= self.HIGH_FREQUENCY_BONUS - - return round(score, 2) - - -class SynergyDeckBuilder: - """Builds an optimized deck from a synergy-scored card pool.""" - - def __init__(self, analyzer: Optional[SynergyAnalyzer] = None): - """ - Initialize synergy deck builder. - - Args: - analyzer: SynergyAnalyzer instance (creates new if None) - """ - self.analyzer = analyzer or SynergyAnalyzer() - - def _allocate_basic_lands( - self, - selected_cards: List[ScoredCard], - by_category: Dict[str, List[ScoredCard]], - pool: CardPool, - ideals: Optional[Dict[str, int]] - ) -> List[ScoredCard]: - """ - Allocate basic lands based on color identity and remaining land slots. - - Separates basic lands from nonbasics, then allocates basics based on: - 1. Total lands target from ideals - 2. Color identity from config - 3. Current nonbasic land count - - Args: - selected_cards: Currently selected cards (may include basics from pool) - by_category: Cards grouped by category - pool: Card pool with configuration - ideals: Ideal card counts - - Returns: - Updated list of selected cards with properly allocated basics - """ - if not ideals: - return selected_cards # No ideals, keep as-is - - # Get basic land names - basic_names = bu.basic_land_names() - - # Separate basics from nonbasics - nonbasic_cards = [c for c in selected_cards if c.name not in basic_names] - - # Calculate how many basics we need - # Note: For nonbasics, count=1 per card (singleton rule), so count == number of unique cards - target_lands = ideals.get("lands", 35) - nonbasic_lands = [c for c in nonbasic_cards if c.category == "Land"] - current_nonbasic_count = len(nonbasic_lands) - - # If we have too many nonbasics, trim them - if current_nonbasic_count > target_lands: - logger.info(f"[Synergy] Too many nonbasics ({current_nonbasic_count}), trimming to {target_lands}") - # Keep the highest scoring nonbasics - sorted_nonbasic_lands = sorted(nonbasic_lands, key=lambda c: c.synergy_score, reverse=True) - trimmed_nonbasic_lands = sorted_nonbasic_lands[:target_lands] - # Update nonbasic_cards to exclude trimmed lands - other_nonbasics = [c for c in nonbasic_cards if c.category != "Land"] - nonbasic_cards = other_nonbasics + trimmed_nonbasic_lands - return nonbasic_cards # No room for basics - - needed_basics = max(0, target_lands - current_nonbasic_count) - - if needed_basics == 0: - logger.info("[Synergy] No basic lands needed (nonbasics exactly fill target)") - return nonbasic_cards - - logger.info(f"[Synergy] Need {needed_basics} basics to fill {target_lands} land target (have {current_nonbasic_count} nonbasics)") - - # Get color identity from config - color_identity = pool.config.get("colors", []) - if not color_identity: - logger.warning(f"[Synergy] No color identity in config (keys: {list(pool.config.keys())}), skipping basic land allocation") - return nonbasic_cards - - # Map colors to basic land names - from code.deck_builder import builder_constants as bc - basic_map = getattr(bc, 'BASIC_LAND_MAPPING', { - 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest' - }) - - # Allocate basics evenly across colors - allocation: Dict[str, int] = {} - colors = [c.upper() for c in color_identity if c.upper() in basic_map] - - if not colors: - logger.warning(f"[Synergy] No valid colors found in identity: {color_identity}") - return nonbasic_cards - - # Distribute basics evenly, with remainder going to first colors - n = len(colors) - base = needed_basics // n - rem = needed_basics % n - - for idx, color in enumerate(sorted(colors)): # sorted for deterministic allocation - count = base + (1 if idx < rem else 0) - land_name = basic_map.get(color) - if land_name: - allocation[land_name] = count - - # Create ScoredCard objects for basics - basic_cards = [] - for land_name, count in allocation.items(): - # Try to get type_line from cache first (most reliable) - type_line = self.analyzer._type_line_cache.get(land_name.lower(), "") - if not type_line: - # Fallback: construct from land name - type_line = f"Basic Land — {land_name[:-1] if land_name.endswith('s') else land_name}" - - # Try to get existing scored data from pool, else create minimal entry - if land_name in pool.cards: - existing = pool.cards[land_name] - basic_card = ScoredCard( - name=land_name, - frequency=existing.frequency, - appearance_count=existing.appearance_count, - synergy_score=existing.synergy_score, - category="Land", - role="basic", - tags=[], - count=count, - type_line=type_line # Use looked-up type_line - ) - else: - # Not in pool (common for basics), create minimal entry - basic_card = ScoredCard( - name=land_name, - frequency=1.0, # Assume high frequency for basics - appearance_count=pool.total_builds, - synergy_score=50.0, # Neutral score - category="Land", - role="basic", - tags=[], - count=count, - type_line=type_line - ) - basic_cards.append(basic_card) - - # Update by_category to replace old basics with new allocation - land_category = by_category.get("Land", []) - land_category = [c for c in land_category if c.name not in basic_names] # Remove old basics - land_category.extend(basic_cards) # Add new basics - by_category["Land"] = land_category - - # Combine and return - result = nonbasic_cards + basic_cards - logger.info(f"[Synergy] Allocated {needed_basics} basic lands across {len(colors)} colors: {allocation}") - return result - - def build_deck( - self, - pool: CardPool, - ideals: Optional[Dict[str, int]] = None, - target_size: int = 99 # Commander + 99 cards = 100 - ) -> Dict[str, Any]: - """ - Build an optimized deck from the card pool, respecting ideal counts. - - Selects highest-scoring cards by category to meet ideal distributions. - - Args: - pool: CardPool with scored cards - ideals: Target card counts by category (e.g., {"Creature": 25, "Land": 35}) - target_size: Total number of cards to include (default 99, excluding commander) - - Returns: - Dict with deck list and metadata - """ - logger.info(f"[Synergy] Building deck from pool of {len(pool.cards)} cards") - - # Map category names to ideal keys (case-insensitive matching) - category_mapping = { - "Creature": "creatures", - "Land": "lands", - "Artifact": "artifacts", - "Enchantment": "enchantments", - "Instant": "instants", - "Sorcery": "sorceries", - "Planeswalker": "planeswalkers", - "Battle": "battles" - } - - selected_cards: List[ScoredCard] = [] - by_category: Dict[str, List[ScoredCard]] = {} - - if ideals: - # Build by category to meet ideals (±2 tolerance) - logger.info(f"[Synergy] Using ideals: {ideals}") - - # Get basic land names for filtering - basic_names = bu.basic_land_names() - - for category in ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"]: - ideal_key = category_mapping.get(category, category.lower()) - target_count = ideals.get(ideal_key, 0) - - if target_count == 0: - continue - - # Get all cards in this category sorted by score - all_category_cards = pool.get_by_category(category) - - # For lands: only select nonbasics (basics allocated separately based on color identity) - if category == "Land": - # Filter out basics - nonbasic_lands = [c for c in all_category_cards if c.name not in basic_names] - category_cards = sorted( - nonbasic_lands, - key=lambda c: c.synergy_score, - reverse=True - ) - # Reserve space for basics - typically want 15-20 basics minimum - # So select fewer nonbasics to leave room - min_basics_estimate = 15 # Reasonable minimum for most decks - max_nonbasics = max(0, target_count - min_basics_estimate) - selected = category_cards[:max_nonbasics] - logger.info(f"[Synergy] Land: selected {len(selected)} nonbasics (max {max_nonbasics}, leaving room for basics)") - else: - category_cards = sorted( - all_category_cards, - key=lambda c: c.synergy_score, - reverse=True - ) - # Select top cards up to target count - selected = category_cards[:target_count] - - selected_cards.extend(selected) - by_category[category] = selected - - logger.info( - f"[Synergy] {category}: selected {len(selected)}/{target_count} " - f"(pool had {len(category_cards)} available)" - ) - - # Calculate how many basics we'll need before filling remaining slots - target_lands = ideals.get("lands", 35) - current_land_count = len(by_category.get("Land", [])) - estimated_basics = max(0, target_lands - current_land_count) - - # Fill remaining slots with highest-scoring cards from any category (except Land) - # But reserve space for basic lands that will be added later - remaining_slots = target_size - len(selected_cards) - estimated_basics - if remaining_slots > 0: - selected_names = {c.name for c in selected_cards} - # Exclude Land category from filler to avoid over-selecting lands - remaining_pool = [ - c for c in pool.get_top_cards(limit=len(pool.cards)) - if c.name not in selected_names and c.category != "Land" - ] - filler_cards = remaining_pool[:remaining_slots] - selected_cards.extend(filler_cards) - - # Add filler cards to by_category - for card in filler_cards: - by_category.setdefault(card.category, []).append(card) - - logger.info(f"[Synergy] Filled {len(filler_cards)} remaining slots (reserved {estimated_basics} for basics)") - else: - # No ideals provided - fall back to top-scoring cards - logger.info("[Synergy] No ideals provided, selecting top-scoring cards") - sorted_cards = pool.get_top_cards(limit=len(pool.cards)) - selected_cards = sorted_cards[:target_size] - - # Group by category for summary - for card in selected_cards: - by_category.setdefault(card.category, []).append(card) - - # Add basic lands after nonbasics are selected - selected_cards = self._allocate_basic_lands(selected_cards, by_category, pool, ideals) - - # Calculate stats (accounting for multi-copy cards) - unique_cards = len(selected_cards) - total_cards = sum(c.count for c in selected_cards) # Actual card count including duplicates - - # Debug: Check for cards with unexpected counts - cards_with_count = [(c.name, c.count) for c in selected_cards if c.count != 1] - if cards_with_count: - logger.info(f"[Synergy Debug] Cards with count != 1: {cards_with_count[:10]}") - - avg_frequency = sum(c.frequency for c in selected_cards) / unique_cards if unique_cards else 0 - avg_score = sum(c.synergy_score for c in selected_cards) / unique_cards if unique_cards else 0 - high_freq_count = len([c for c in selected_cards if c.frequency >= 0.8]) - - logger.info( - f"[Synergy] Built deck: {total_cards} cards ({unique_cards} unique), " - f"avg frequency={avg_frequency:.2f}, avg score={avg_score:.2f}, " - f"high-frequency cards={high_freq_count}" - ) - - return { - "cards": selected_cards, - "by_category": by_category, - "total_cards": total_cards, # Actual count including duplicates - "unique_cards": unique_cards, # Unique card types - "avg_frequency": round(avg_frequency, 3), - "avg_score": round(avg_score, 2), - "high_frequency_count": high_freq_count, - "commander": pool.config.get("commander"), - "themes": pool.themes - } - - -# Global analyzer instance -_analyzer = SynergyAnalyzer() -_builder = SynergyDeckBuilder(_analyzer) - - -def analyze_and_build_synergy_deck( - builds: List[Dict[str, Any]], - config: Dict[str, Any] -) -> Dict[str, Any]: - """ - Convenience function to analyze builds and create synergy deck in one call. - - Args: - builds: List of build results - config: Original deck configuration (includes ideals) - - Returns: - Synergy deck result dict - """ - pool = _analyzer.analyze_builds(builds, config) - ideals = config.get("ideals", {}) - deck = _builder.build_deck(pool, ideals=ideals) - return deck diff --git a/code/web/services/theme_catalog_loader.py b/code/web/services/theme_catalog_loader.py index e7c6247..9212b78 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 + from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore except ImportError: # pragma: no cover - fallback path try: - from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry + from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore 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") + idx: SlugThemeIndex | None = _CACHE.get("index") # type: ignore 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: + with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type] 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"] + return _CACHE["index"] # type: ignore 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 + from scripts.build_theme_catalog import load_catalog_yaml # type: ignore 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] + archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] # type: ignore[arg-type] 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 cc406af..d1d3991 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 + yaml = None # type: ignore 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 -from .preview_metrics import record_redis_get, record_redis_store +from .preview_cache_backend import redis_get # type: ignore +from .preview_metrics import record_redis_get, record_redis_store # type: ignore # 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 +TTL_SECONDS = ttl_seconds # type: ignore # 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 + _CURATED_SYNERGY_MATRIX = pairs # type: ignore else: _CURATED_SYNERGY_MATRIX = None else: diff --git a/code/web/static/js_backup_pre_typescript/app.js b/code/web/static/app.js similarity index 100% rename from code/web/static/js_backup_pre_typescript/app.js rename to code/web/static/app.js diff --git a/code/web/static/css_backup_pre_tailwind/styles.css b/code/web/static/css_backup_pre_tailwind/styles.css deleted file mode 100644 index eda7352..0000000 --- a/code/web/static/css_backup_pre_tailwind/styles.css +++ /dev/null @@ -1,1208 +0,0 @@ -/* 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/js_backup_pre_typescript/components.js b/code/web/static/js_backup_pre_typescript/components.js deleted file mode 100644 index de4021c..0000000 --- a/code/web/static/js_backup_pre_typescript/components.js +++ /dev/null @@ -1,375 +0,0 @@ -/** - * 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 = '
'; - tags.forEach(tag => { - const isHighlight = highlightTags.includes(tag); - tagsHTML += `${tag}`; - }); - tagsHTML += '
'; - } - - let roleHTML = ''; - if (role) { - roleHTML = `
Role: ${role}
`; - } - - let flipButtonHTML = ''; - if (isDFC) { - flipButtonHTML = ` - - `; - } - - popup.innerHTML = ` -
-
-
- ${cardName} image - ${flipButtonHTML} -
-
-

${cardName}

- ${roleHTML} - ${tagsHTML} -
- -
- `; - - 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 deleted file mode 100644 index 986f565..0000000 --- a/code/web/static/shared-components.css +++ /dev/null @@ -1,655 +0,0 @@ -/* 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 d0593a6..eda7352 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -1,2848 +1,738 @@ -/* 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; - /* 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 */ + --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; /* slightly warm muted */ + --border: #d6d1c7; /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; } [data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; } - [data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; } - [data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; - /* darker green */ - --red-main: #c62828; - /* deeper red */ - --blue-main: #1565c0; - /* balanced blue */ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ } - -*{ - box-sizing:border-box -} - -html{ - height:100%; - overflow-x:hidden; - overflow-y:scroll; - max-width:100vw; -} - +*{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: scroll; + 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; -} - +[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; +.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; -} - +.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; + 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 */ - +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; - overflow-x: hidden; -} - +.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; -} - +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; - } - - .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; - } + :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 */ - } + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } } -.brand h1{ - display:none; -} +.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{ - 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); -} +.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; -} +.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; - } -} +.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; -} - +.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; + .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); -} - +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; - -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); -} +.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: 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); -} +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } /* Skeletons */ - -[data-skeleton]{ - position: relative; -} - -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ - opacity: 0; -} - -[data-skeleton-placeholder]{ - display:none; - pointer-events:none; -} - -[data-skeleton].is-loading > [data-skeleton-placeholder]{ - display:flex; - flex-direction:column; - opacity:1; -} - +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } [data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ - display:none !important; -} - +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } [data-skeleton]::after{ - content: ''; - position: absolute; - inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; } - -[data-skeleton].is-loading::after{ - display:block; -} - +[data-skeleton].is-loading::after{ display:block; } [data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); -} - -[data-skeleton][data-skeleton-label=""]::before{ - content:''; -} - -@keyframes shimmer{ - 0%{ - background-position: 200% 0; - } - - 100%{ - background-position: -200% 0; - } + 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; -} +.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; -} +.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; + 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; -} - -.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; + 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; + 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; -} - -.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; -} +.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: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; + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; } /* Step 1 candidate grid (200px-wide scaled images) */ - .candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; } - .candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; -} - -.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; + 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); -} +.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:var(--bg); - font-size:12px; -} - -.stage-nav .name { - font-size:12px; -} +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } +.stage-nav .name { font-size:12px; } /* Build controls sticky box tweaks */ - -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: 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); +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); } @media (max-width: 1024px){ - :root { - --banner-offset: 56px; - } - - .build-controls { - position: fixed !important; - /* Fixed to viewport instead of sticky */ - bottom: 0 !important; - /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; - /* Override top positioning */ - border-radius: 0 !important; - /* Remove border radius for full width */ - margin: 0 !important; - /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; - /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; - /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; - /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; - /* Higher z-index to ensure it's above content */ - } + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } } - @media (min-width: 721px){ - :root { - --banner-offset: 48px; - } + :root { --banner-offset: 48px; } } /* Progress bar */ - -.progress { - position: relative; - height: 10px; - background: var(--panel); - border:1px solid var(--border); - border-radius: 999px; - overflow: hidden; -} - -.progress .bar { - position:absolute; - left:0; - top:0; - bottom:0; - width: 0%; - background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); -} - -.progress.flash { - box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; -} +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } /* Chips */ - -.chip { - display:inline-flex; - align-items:center; - gap:.35rem; - background: var(--panel); - border:1px solid var(--border); - color:var(--text); - border-radius:999px; - padding:.2rem .55rem; - font-size:12px; -} - -.chip .dot { - width:8px; - height:8px; - border-radius:50%; - background:#6b7280; -} - -.chip: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; -} +.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; -} +.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; - -webkit-user-select:none; - -moz-user-select:none; - 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; 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 */ + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } - 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; } - /* 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 & 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: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); - border-color: var(--text); + background: #1f2937; + border-color: #374151; } .analytics-accordion summary:active { - transform: scale(0.99); + transform: scale(0.99); } .analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; } .analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; + animation: accordion-slide-down 0.3s ease-out; } @keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; + animation: shimmer 1.5s infinite; } @keyframes shimmer { - 0% { - background-position: -200% 0; - } - - 100% { - background-position: 200% 0; - } + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } /* Ideals Slider Styling */ - .ideals-slider { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; + -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; - -webkit-transition: all 0.15s ease; - transition: all 0.15s ease; + -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); + 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; - -moz-transition: all 0.15s ease; - transition: all 0.15s ease; + 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); + 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; } /* ======================================== @@ -2850,2840 +740,469 @@ img.lqip.loaded { ======================================== */ /* 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 { - -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; + 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%; - -o-object-fit: contain; - object-fit: contain; - transition: transform 0.3s ease; + 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); + 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 deleted file mode 100644 index f8d085c..0000000 --- a/code/web/static/tailwind.css +++ /dev/null @@ -1,3537 +0,0 @@ -/* 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 deleted file mode 100644 index badfa20..0000000 --- a/code/web/static/ts/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# 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 deleted file mode 100644 index 3e276eb..0000000 --- a/code/web/static/ts/app.ts +++ /dev/null @@ -1,1702 +0,0 @@ -/* 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 deleted file mode 100644 index 15f0836..0000000 --- a/code/web/static/ts/cardHover.ts +++ /dev/null @@ -1,798 +0,0 @@ -/** - * 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 deleted file mode 100644 index b7f8455..0000000 --- a/code/web/static/ts/cardImages.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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 deleted file mode 100644 index b9493b2..0000000 --- a/code/web/static/ts/components.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * 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 = '
      '; - tags.forEach(tag => { - const isHighlight = highlightTags.includes(tag); - tagsHTML += `${tag}`; - }); - tagsHTML += '
      '; - } - - let roleHTML = ''; - if (role) { - roleHTML = `
      Role: ${role}
      `; - } - - let flipButtonHTML = ''; - if (isDFC) { - flipButtonHTML = ` - - `; - } - - popup.innerHTML = ` -
      -
      -
      - ${cardName} image - ${flipButtonHTML} -
      -
      -

      ${cardName}

      - ${roleHTML} - ${tagsHTML} -
      - -
      - `; - - 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 deleted file mode 100644 index bb7fb65..0000000 --- a/code/web/static/ts/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* 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 c17b53f..72996c3 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -6,6 +6,10 @@ 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 7afd820..cc5277c 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 = partner_secondary_name|card_image('normal') %} + {% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=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,83 +224,36 @@ }); } document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); }); - - 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 = []; + function updatePartnerRecommendations(tags){ + if (!reco) return; + Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); }); + var unique = []; var seen = new Set(); - - // 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){ + (Array.isArray(tags) ? tags : []).forEach(function(tag){ var value = String(tag || '').trim(); if (!value) return; var key = value.toLowerCase(); if (seen.has(key)) return; seen.add(key); - combined.push({ tag: value, element: null, isPartner: true }); + unique.push(value); }); - - // 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 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); } }); - - // Update visibility of recommended section - var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0; + var hasAny = reco.querySelectorAll('button.chip-reco').length > 0; if (recoBlock){ - recoBlock.style.display = hasAnyReco ? '' : 'none'; - recoBlock.setAttribute('data-has-reco', hasAnyReco ? '1' : '0'); + recoBlock.style.display = hasAny ? '' : 'none'; + recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0'); } - if (selAll){ selAll.style.display = hasAnyReco ? '' : 'none'; } - + if (selAll){ selAll.style.display = hasAny ? '' : 'none'; } updateUI(); } @@ -311,11 +264,11 @@ if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){ tags = detail.payload.theme_tags; } - updatePartnerTags(tags); + updatePartnerRecommendations(tags); }); var initialPartnerTags = readPartnerPreviewTags(); - updatePartnerTags(initialPartnerTags); + updatePartnerRecommendations(initialPartnerTags); updateUI(); })(); diff --git a/code/web/templates/build/_partner_controls.html b/code/web/templates/build/_partner_controls.html index 3de6a96..202bf7d 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 = partner_preview.secondary_name|card_image('normal') %} + {% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=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 '/api/images/normal/' + encodeURIComponent(name); + return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal'; } function buildScryfallUrl(name){ if (!name) return ''; @@ -528,9 +528,7 @@ var colorLabel = payload.color_label || ''; var secondaryName = payload.secondary_name || payload.name || ''; var primary = payload.primary_name || primaryName; - // 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 themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : []; 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 dfea715..65e61b8 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) %} - {{ selected }} card image + {{ selected }} card image
      diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html index 0186eaa..6ad2ef7 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) %} - {{ commander.name }} card image + {{ commander.name }} card image {% 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 = partner_name_base|card_image('normal') %} + {% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=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 %} - {{ (partner_secondary_name or 'Selected card') ~ ' card image' }} {% else %} {{ (partner_secondary_name or 'Selected card') ~ ' card image' }} diff --git a/code/web/templates/build/_step3.html b/code/web/templates/build/_step3.html index 95e7a39..8231e5b 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) %} - {{ commander }} card image + {{ commander }} card image
      diff --git a/code/web/templates/build/_step3_skeleton.html b/code/web/templates/build/_step3_skeleton.html deleted file mode 100644 index 02f6a82..0000000 --- a/code/web/templates/build/_step3_skeleton.html +++ /dev/null @@ -1,32 +0,0 @@ -
      -
      - -
      -
      - -
      -
      -

      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 ca989b5..47b986f 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) %} - {{ commander }} card image + {{ commander }} card image
      diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 58b7237..b1d4b88 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 %}> - {{ commander }} card image
      -
      + -
      +
      {{ partner_role_label }}: {{ partner_secondary_name }}
      -
      +
      Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
      {% if combined.color_label %} -
      +
      Colors: {{ combined.color_label }}
      {% endif %} {% if partner_theme_tags %} -
      +
      Theme emphasis: {{ partner_theme_tags|join(', ') }}
      {% endif %} {% endif %} {% if status and status.startswith('Build complete') %} -
      +
      {% if csv_path %} -
      +
      {% endif %} {% if txt_path %} -
      +
      @@ -150,64 +150,64 @@ {% endif %}

      {% if show_color_identity %} -
      +
      {{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }} {{ color_label }}
      {% endif %}

      Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}

      -
      +
      Owned-only: {{ 'On' if owned_only else 'Off' }} -
      - +
      +
      Prefer-owned: {{ 'On' if prefer_owned else 'Off' }}
      MDFC swap: {{ 'On' if swap_mdfc_basics else 'Off' }}
      - Manage Owned Library + Manage Owned Library

      Bracket: {{ bracket }}

      -
      +
      {% if i and n %} Stage {{ i }}/{{ n }} {% endif %} {% set deck_count = (total_cards if total_cards is not none else 0) %} - Deck {{ deck_count }}/100 + Deck {{ deck_count }}/100 {% if added_total is not none %} - Added {{ added_total }} + Added {{ added_total }} {% endif %} {% if prefer_combos %} - Combos: {{ combo_target_count }} ({{ combo_balance }}) + Combos: {{ combo_target_count }} ({{ combo_balance }}) {% endif %} {% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %} - Clamped {{ clamped_overflow }} + Clamped {{ clamped_overflow }} {% endif %} {% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %} - {{ mc_summary }} + {{ mc_summary }} {% endif %} {% if locks and locks|length > 0 %}🔒 {{ locks|length }} locked{% endif %} -
      {% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %} {% set pct_clamped = (pct if pct <= 100 else 100) %} {% set pct_int = pct_clamped|int %} -
      +
      {% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %} -
      Adjusted targets: {{ mc_adjustments|join(', ') }}
      +
      Adjusted targets: {{ mc_adjustments|join(', ') }}
      {% endif %} {% if status %} -
      +
      Status: {{ status }}{% if stage_label %} — {{ stage_label }}{% endif %}
      {% endif %} {% if gated and (not status or not status.startswith('Build complete')) %} -
      +
      Compliance gating active — resolve violations above (replace or remove cards) to continue.
      {% endif %} @@ -220,15 +220,15 @@ {% if locked_cards is defined and locked_cards %} {% from 'partials/_macros.html' import lock_button %} -
      +
      Locked cards (always kept) -
        +
          {% for lk in locked_cards %} -
        • +
        • {{ lk.name }} {% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %} {% if lk.in_deck %}• In deck{% else %}• Will be included on rerun{% endif %} -
          +
          {{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
        • @@ -238,7 +238,7 @@ {% endif %} -
          +
          @@ -248,10 +248,10 @@ -
          - - - -{% endblock %} diff --git a/code/web/templates/errors/404.html b/code/web/templates/errors/404.html index 3470de0..f091901 100644 --- a/code/web/templates/errors/404.html +++ b/code/web/templates/errors/404.html @@ -1,25 +1,13 @@ {% extends "base.html" %} -{% from 'partials/_buttons.html' import button %} -{% from 'partials/_panels.html' import simple_panel %} - {% block content %}
          - {{ simple_panel( - title='Page Not Found', - content='

          The page you requested could not be found.

          -

          Request ID: ' ~ (request_id or request.state.request_id) ~ '

          ', - variant='bordered', - padding='lg' - ) }} - -
          - {{ button('Go Home', variant='primary', href='/') }} -
          - -
          +

          Page not found

          +

          The page you requested could not be found.

          +

          Request ID: {{ request_id or request.state.request_id }}

          +

          Go home

          +
          Details
          Status: {{ status }}
           Path: {{ request.url.path }}
          -
          {% endblock %} diff --git a/code/web/templates/errors/500.html b/code/web/templates/errors/500.html index 42c47ab..1beb0b7 100644 --- a/code/web/templates/errors/500.html +++ b/code/web/templates/errors/500.html @@ -1,17 +1,8 @@ {% extends "base.html" %} -{% from 'partials/_buttons.html' import button %} -{% from 'partials/_panels.html' import info_panel %} - {% block content %}
          - {{ info_panel( - icon='❌', - title='Internal Server Error', - content='Something went wrong. Our team has been notified.

          - Request ID: ' ~ (request_id or request.state.request_id) ~ '', - type='error', - action_text='Go Home', - action_href='/' - ) }} -
          +

          Internal Server Error

          +

          Something went wrong.

          +

          Request ID: {{ request_id or request.state.request_id }}

          +

          Go home

          {% endblock %} diff --git a/code/web/templates/home.html b/code/web/templates/home.html index 5815415..2f6c5fd 100644 --- a/code/web/templates/home.html +++ b/code/web/templates/home.html @@ -1,20 +1,18 @@ {% extends "base.html" %} -{% from 'partials/_buttons.html' import button %} - {% block content %}
          - {{ button('Build a Deck', variant='primary', href='/build', classes='action-button home-button') }} - {{ button('Run a JSON Config', variant='secondary', href='/configs', classes='action-button home-button') }} - {% if show_setup %}{{ button('Initial Setup', variant='secondary', href='/setup', classes='action-button home-button') }}{% endif %} - {{ button('Owned Library', variant='secondary', href='/owned', classes='action-button home-button') }} - {{ button('Browse All Cards', variant='secondary', href='/cards', classes='action-button home-button') }} - {% if show_commanders %}{{ button('Browse Commanders', variant='secondary', href='/commanders', classes='action-button home-button') }}{% endif %} - {{ button('Finished Decks', variant='secondary', href='/decks', classes='action-button home-button') }} - {{ button('Browse Themes', variant='secondary', href='/themes/', classes='action-button home-button') }} - {% if random_ui %}{{ button('Random Build', variant='secondary', href='/random', classes='action-button home-button') }}{% endif %} - {% if show_diagnostics %}{{ button('Diagnostics', variant='secondary', href='/diagnostics', classes='action-button home-button') }}{% endif %} - {% if show_logs %}{{ button('View Logs', variant='secondary', href='/logs', classes='action-button home-button') }}{% endif %} + Build a Deck + Run a JSON Config + {% if show_setup %}Initial Setup{% endif %} + Owned Library + Browse All Cards + {% if show_commanders %}Browse Commanders{% endif %} + Finished Decks + Browse Themes + {% if random_ui %}Random Build{% endif %} + {% if show_diagnostics %}Diagnostics{% endif %} + {% if show_logs %}View Logs{% endif %}
          Themes: … diff --git a/code/web/templates/owned/index.html b/code/web/templates/owned/index.html index 9259514..73b5cfe 100644 --- a/code/web/templates/owned/index.html +++ b/code/web/templates/owned/index.html @@ -38,22 +38,22 @@ {% if names and names|length %}
          - - {% for t in all_types %}{% endfor %} - {% for t in all_tags %}{% endfor %} - {% for c in all_colors %}{% endfor %} {% if color_combos and color_combos|length %} @@ -63,14 +63,14 @@ {% endfor %} {% endif %} - +
          {% endif %} {% if names and names|length %} -
          800 %}data-virtualize="1"{% endif %}> +
          800 %}data-virtualize="1"{% endif %}>
            {% for n in names %} {% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %} @@ -81,8 +81,8 @@