diff --git a/.env.example b/.env.example index 75119f7..5921ede 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,9 @@ WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1" WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4" WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0" +# Card Image Caching (optional, uses Scryfall bulk data API) +CACHE_CARD_IMAGES=1 # dockerhub: CACHE_CARD_IMAGES="1" (1=download images to card_files/images/, 0=fetch from Scryfall API on demand) + # Build Stage Ordering WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill diff --git a/.gitignore b/.gitignore index f8e1a3c..6de24ec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ RELEASE_NOTES.md test.py +test_*.py !test_exclude_cards.txt !test_include_exclude_config.json @@ -40,4 +41,14 @@ logs/ logs/* !logs/perf/ logs/perf/* -!logs/perf/theme_preview_warm_baseline.json \ No newline at end of file +!logs/perf/theme_preview_warm_baseline.json + +# Node.js and build artifacts +node_modules/ +code/web/static/js/ +code/web/static/styles.css +*.js.map + +# Keep TypeScript sources and Tailwind CSS input +!code/web/static/ts/ +!code/web/static/tailwind.css \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c6db31c..2351a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,56 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -- **Build X and Compare** feature: Build multiple decks with same configuration and compare results side-by-side - - Build 1-10 decks in parallel to see variance from card selection randomness - - Real-time progress tracking with dynamic time estimates based on color count - - Comparison view with card overlap statistics and individual build summaries - - Smart filtering excludes guaranteed cards (basics, staples) from "Most Common Cards" - - Card hover support throughout comparison interface - - Rebuild button to rerun same configuration - - Export all decks as ZIP archive +- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates + - Validates Jinja2 syntax across all templates + - Checks HTML structure (balanced tags, unique IDs, proper attributes) + - Basic accessibility validation (alt text, form labels, button types) + - Regression prevention thresholds to maintain code quality +- **Code Quality Tools**: Enhanced development tooling for maintainability + - Automated utilities for code cleanup + - Improved type checking configuration +- **Card Image Caching**: Optional local image cache for faster card display + - Downloads card images from Scryfall bulk data (respects API guidelines) + - Graceful fallback to Scryfall API for uncached images + - Enabled via `CACHE_CARD_IMAGES=1` environment variable + - Integrated with setup/tagging process + - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) +- **Component Library**: Living documentation of reusable UI components at `/docs/components` + - Interactive examples of all buttons, modals, forms, cards, and panels + - Jinja2 macros for consistent component usage + - Component partial templates for reuse across pages +- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety + - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript + - Created shared type definitions for state management, telemetry, HTMX, and UI components + - Integrated TypeScript compilation into build process (`npm run build:ts`) + - Compiled JavaScript output in `code/web/static/js/` directory + - Docker build automatically compiles TypeScript during image creation + +### Changed +- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) +- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules + - Created `cardHover.ts` for unified hover panel functionality + - Created `cardImages.ts` for card image loading with automatic retry fallbacks + - Reduced inline script size in base template for better maintainability +- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture + - Tailwind CSS v3 with custom MTG color palette + - PostCSS build pipeline with autoprefixer + - Reduced inline styles in templates (moved to shared CSS classes) + - Organized CSS into functional sections with clear documentation +- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes + - Light mode: Darker text for improved readability, warm earth tone color palette + - Dark mode: Refined contrast for better visual hierarchy + - High-contrast mode: Optimized for maximum accessibility + - Consistent hover states across all interactive elements + - Improved visibility of form inputs and controls +- **JavaScript Modernization**: Updated to modern JavaScript patterns + - Converted `var` declarations to `const`/`let` + - Added TypeScript type annotations for better IDE support and error catching + - Consolidated event handlers and utility functions +- **Docker Build Optimization**: Improved developer experience + - Hot reload enabled for templates and static files + - Volume mounts for rapid iteration without rebuilds +- **Template Modernization**: Migrated templates to use component system - **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) - 10% bonus for cards appearing in 80%+ of builds @@ -27,18 +69,46 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) - Detailed progress logging for multi-build orchestration - User guide: `docs/user_guides/batch_build_compare.md` +- **Web UI Component Library**: Standardized UI components for consistent design across all pages + - 5 component partial template files (buttons, modals, forms, cards, panels) + - ~900 lines of component CSS styles + - Interactive JavaScript utilities (components.js) + - Living component library page at `/docs/components` + - 1600+ lines developer documentation (component_catalog.md) +- **Custom UI Enhancements**: + - Darker gray styling for home page buttons + - Visual highlighting for selected theme chips in deck builder ### Changed -_None_ +- Migrated 5 templates to new component system (home, 404, 500, setup, commanders) +- **Type Checking Configuration**: Improved Python code quality tooling + - Configured type checker for better error detection + - Optimized linting rules for development workflow + +### Fixed +- **Template Quality**: Resolved HTML structure issues found by validation tests + - Fixed duplicate ID attributes in build wizard and theme picker templates + - Removed erroneous block tags from component documentation + - Corrected template structure for HTMX fragments +- **Code Quality**: Resolved type checking warnings and improved code maintainability + - Fixed type annotation inconsistencies + - Cleaned up redundant code quality suppressions + - Corrected configuration conflicts ### Removed _None_ -### Fixed -_None_ - ### Performance -_None_ +- Hot reload for CSS/template changes (no Docker rebuild needed) +- Optional image caching reduces Scryfall API calls +- Faster page loads with optimized CSS +- TypeScript compilation produces optimized JavaScript + +### For Users +- Faster card image loading with optional caching +- Cleaner, more consistent web UI design +- Improved page load performance +- More reliable JavaScript behavior ### Deprecated _None_ diff --git a/DOCKER.md b/DOCKER.md index 398140c..99c9907 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -283,6 +283,7 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. | | `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. | +| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/Dockerfile b/Dockerfile index 7f6f0ce..1f76105 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,21 +10,42 @@ ENV PYTHONUNBUFFERED=1 ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} -# Install system dependencies if needed +# Install system dependencies including Node.js RUN apt-get update && apt-get install -y \ gcc \ + curl \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Copy requirements first for better caching +# Copy package files for Node.js dependencies +COPY package.json package-lock.json* ./ + +# Install Node.js dependencies +RUN npm install + +# Copy Tailwind/TypeScript config files +COPY tailwind.config.js postcss.config.js tsconfig.json ./ + +# Copy requirements for Python dependencies (for better caching) COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy application code +# Copy Python application code (includes templates needed for Tailwind) COPY code/ ./code/ COPY mypy.ini . +# Tailwind source is already in code/web/static/tailwind.css from COPY code/ +# TypeScript sources are in code/web/static/ts/ from COPY code/ + +# Force fresh CSS build by removing any copied styles.css +RUN rm -f ./code/web/static/styles.css + +# Build CSS and TypeScript +RUN npm run build + # Copy default configs in two locations: # 1) /app/config is the live path (may be overlaid by a volume) # 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted @@ -36,7 +57,9 @@ RUN mkdir -p owned_cards # Store in /.defaults/card_files so it persists after volume mount RUN mkdir -p /.defaults/card_files # Copy entire card_files directory (will include cache if present, empty if not) -COPY card_files/ /.defaults/card_files/ +# COMMENTED OUT FOR LOCAL DEV: card_files is mounted as volume anyway +# Uncomment for production builds or CI/CD +# COPY card_files/ /.defaults/card_files/ # Create necessary directories as mount points RUN mkdir -p deck_files logs csv_files card_files config /.defaults diff --git a/README.md b/README.md index e979b3a..5d46b02 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. | | `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). | +| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). Requires ~3-6 GB. See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c71d6af..f03d5c5 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,36 +3,106 @@ ## [Unreleased] ### Summary -Major new feature: Build X and Compare with Intelligent Synergy Builder. Run the same deck configuration multiple times to see variance, compare results side-by-side, and create optimized "best-of" decks. +Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability. ### Added -- **Build X and Compare**: Build 1-10 decks in parallel with same configuration - - Side-by-side comparison with card overlap statistics - - Smart filtering of guaranteed cards - - Rebuild button for quick iterations - - ZIP export of all builds -- **Synergy Builder**: Create optimized deck from multiple builds - - Intelligent scoring (frequency + EDHREC + themes) - - Color-coded synergy preview - - Full metadata export (CSV/TXT/JSON) - - Partner commander support -- Feature flag: `ENABLE_BATCH_BUILD` (default: on) -- User guide: `docs/user_guides/batch_build_compare.md` +- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality + - Validates Jinja2 syntax and structure + - Checks for common HTML issues (duplicate IDs, balanced tags) + - Basic accessibility validation + - Prevents regression in template quality +- **Code Quality Tools**: Enhanced development tooling for maintainability + - Automated utilities for code cleanup + - Improved type checking configuration +- **Card Image Caching**: Optional local image cache for faster card display + - Downloads card images from Scryfall bulk data (respects API guidelines) + - Graceful fallback to Scryfall API for uncached images + - Enabled via `CACHE_CARD_IMAGES=1` environment variable + - Integrated with setup/tagging process + - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) +- **Component Library**: Living documentation of reusable UI components at `/docs/components` + - Interactive examples of all buttons, modals, forms, cards, and panels + - Jinja2 macros for consistent component usage + - Component partial templates for reuse across pages +- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety + - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript + - Created shared type definitions for state management, telemetry, HTMX, and UI components + - Integrated TypeScript compilation into build process (`npm run build:ts`) + - Compiled JavaScript output in `code/web/static/js/` directory + - Docker build automatically compiles TypeScript during image creation ### Changed -_None_ +- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) +- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules + - Created `cardHover.ts` for unified hover panel functionality + - Created `cardImages.ts` for card image loading with automatic retry fallbacks + - Reduced inline script size in base template for better maintainability +- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture + - Tailwind CSS v3 with custom MTG color palette + - PostCSS build pipeline with autoprefixer + - Reduced inline styles in templates (moved to shared CSS classes) + - Organized CSS into functional sections with clear documentation +- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes + - Light mode: Darker text for improved readability, warm earth tone color palette + - Dark mode: Refined contrast for better visual hierarchy + - High-contrast mode: Optimized for maximum accessibility + - Consistent hover states across all interactive elements + - Improved visibility of form inputs and controls +- **JavaScript Modernization**: Updated to modern JavaScript patterns + - Converted `var` declarations to `const`/`let` + - Added TypeScript type annotations for better IDE support and error catching + - Consolidated event handlers and utility functions +- **Docker Build Optimization**: Improved developer experience + - Hot reload enabled for templates and static files + - Volume mounts for rapid iteration without rebuilds +- **Template Modernization**: Migrated templates to use component system +- **Type Checking Configuration**: Improved Python code quality tooling + - Configured type checker for better error detection + - Optimized linting rules for development workflow +- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck + - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) + - 10% bonus for cards appearing in 80%+ of builds + - Color-coded synergy scores in preview (green=high, red=low) + - Partner commander support with combined color identity + - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands) + - Export synergy deck with full metadata (CSV, TXT, JSON files) +- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) +- Detailed progress logging for multi-build orchestration +- User guide: `docs/user_guides/batch_build_compare.md` +- **Web UI Component Library**: Standardized UI components for consistent design across all pages + - 5 component partial template files (buttons, modals, forms, cards, panels) + - ~900 lines of component CSS styles + - Interactive JavaScript utilities (components.js) + - Living component library page at `/docs/components` + - 1600+ lines developer documentation (component_catalog.md) +- **Custom UI Enhancements**: + - Darker gray styling for home page buttons + - Visual highlighting for selected theme chips in deck builder ### Removed _None_ ### Fixed -_None_ +- **Template Quality**: Resolved HTML structure issues + - Fixed duplicate ID attributes in templates + - Removed erroneous template block tags + - Corrected structure for HTMX fragments +- **Code Quality**: Resolved type checking warnings and improved code maintainability + - Fixed type annotation inconsistencies + - Cleaned up redundant code quality suppressions + - Corrected configuration conflicts ### Performance -_None_ +- Hot reload for CSS/template changes (no Docker rebuild needed) +- Optional image caching reduces Scryfall API calls +- Faster page loads with optimized CSS +- TypeScript compilation produces optimized JavaScript ### For Users -_No changes yet_ +- Faster card image loading with optional caching +- Cleaner, more consistent web UI design +- Improved page load performance +- More reliable JavaScript behavior ### Deprecated _None_ diff --git a/code/deck_builder/__init__.py b/code/deck_builder/__init__.py index c992bac..9540709 100644 --- a/code/deck_builder/__init__.py +++ b/code/deck_builder/__init__.py @@ -4,6 +4,6 @@ __all__ = ['DeckBuilder'] def __getattr__(name): # Lazy-load DeckBuilder to avoid side effects during import of submodules if name == 'DeckBuilder': - from .builder import DeckBuilder # type: ignore + from .builder import DeckBuilder return DeckBuilder raise AttributeError(name) diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py index 86dedd4..b941f30 100644 --- a/code/deck_builder/background_loader.py +++ b/code/deck_builder/background_loader.py @@ -1,22 +1,18 @@ -"""Loader for background cards derived from `background_cards.csv`.""" +"""Loader for background cards derived from all_cards.parquet.""" from __future__ import annotations import ast -import csv +import re from dataclasses import dataclass from functools import lru_cache from pathlib import Path -import re -from typing import Mapping, Tuple +from typing import Any, Mapping, Tuple from logging_util import get_logger from deck_builder.partner_background_utils import analyze_partner_background -from path_util import csv_dir LOGGER = get_logger(__name__) -BACKGROUND_FILENAME = "background_cards.csv" - @dataclass(frozen=True, slots=True) class BackgroundCard: @@ -57,7 +53,7 @@ class BackgroundCatalog: def load_background_cards( source_path: str | Path | None = None, ) -> BackgroundCatalog: - """Load and cache background card data.""" + """Load and cache background card data from all_cards.parquet.""" resolved = _resolve_background_path(source_path) try: @@ -65,7 +61,7 @@ def load_background_cards( mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) size = stat.st_size except FileNotFoundError: - raise FileNotFoundError(f"Background CSV not found at {resolved}") from None + raise FileNotFoundError(f"Background data not found at {resolved}") from None entries, version = _load_background_cards_cached(str(resolved), mtime_ns) etag = f"{size}-{mtime_ns}-{len(entries)}" @@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B if not path.exists(): return tuple(), "unknown" - with path.open("r", encoding="utf-8", newline="") as handle: - first_line = handle.readline() - version = "unknown" - if first_line.startswith("#"): - version = _parse_version(first_line) - else: - handle.seek(0) - reader = csv.DictReader(handle) - if reader.fieldnames is None: - return tuple(), version - entries = _rows_to_cards(reader) + try: + import pandas as pd + df = pd.read_parquet(path, engine="pyarrow") + + # Filter for background cards + if 'isBackground' not in df.columns: + LOGGER.warning("isBackground column not found in %s", path) + return tuple(), "unknown" + + df_backgrounds = df[df['isBackground']].copy() + + if len(df_backgrounds) == 0: + LOGGER.warning("No background cards found in %s", path) + return tuple(), "unknown" + + entries = _rows_to_cards(df_backgrounds) + version = "parquet" + + except Exception as e: + LOGGER.error("Failed to load backgrounds from %s: %s", path, e) + return tuple(), "unknown" frozen = tuple(entries) return frozen, version def _resolve_background_path(override: str | Path | None) -> Path: + """Resolve path to all_cards.parquet.""" if override: return Path(override).resolve() - return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve() + # Use card_files/processed/all_cards.parquet + return Path("card_files/processed/all_cards.parquet").resolve() -def _parse_version(line: str) -> str: - tokens = line.lstrip("# ").strip().split() - for token in tokens: - if "=" not in token: - continue - key, value = token.split("=", 1) - if key == "version": - return value - return "unknown" - - -def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]: +def _rows_to_cards(df) -> list[BackgroundCard]: + """Convert DataFrame rows to BackgroundCard objects.""" entries: list[BackgroundCard] = [] seen: set[str] = set() - for raw in reader: - if not raw: + + for _, row in df.iterrows(): + if row.empty: continue - card = _row_to_card(raw) + card = _row_to_card(row) if card is None: continue key = card.display_name.lower() @@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]: continue seen.add(key) entries.append(card) + entries.sort(key=lambda card: card.display_name) return entries -def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: - name = _clean_str(row.get("name")) - face_name = _clean_str(row.get("faceName")) or None +def _row_to_card(row) -> BackgroundCard | None: + """Convert a DataFrame row to a BackgroundCard.""" + # Helper to safely get values from DataFrame row + def get_val(key: str): + try: + if hasattr(row, key): + val = getattr(row, key) + # Handle pandas NA/None + if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__): + return None + return val + return None + except Exception: + return None + + name = _clean_str(get_val("name")) + face_name = _clean_str(get_val("faceName")) or None display = face_name or name if not display: return None - type_line = _clean_str(row.get("type")) - oracle_text = _clean_multiline(row.get("text")) - raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags"))) + type_line = _clean_str(get_val("type")) + oracle_text = _clean_multiline(get_val("text")) + raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags"))) detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags) if not detection.is_background: return None @@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: face_name=face_name, display_name=display, slug=_slugify(display), - color_identity=_parse_color_list(row.get("colorIdentity")), - colors=_parse_color_list(row.get("colors")), - mana_cost=_clean_str(row.get("manaCost")), - mana_value=_parse_float(row.get("manaValue")), + color_identity=_parse_color_list(get_val("colorIdentity")), + colors=_parse_color_list(get_val("colors")), + mana_cost=_clean_str(get_val("manaCost")), + mana_value=_parse_float(get_val("manaValue")), type_line=type_line, oracle_text=oracle_text, - keywords=tuple(_split_list(row.get("keywords"))), + keywords=tuple(_split_list(get_val("keywords"))), theme_tags=tuple(tag for tag in raw_theme_tags if tag), raw_theme_tags=raw_theme_tags, - edhrec_rank=_parse_int(row.get("edhrecRank")), - layout=_clean_str(row.get("layout")) or "normal", - side=_clean_str(row.get("side")) or None, + edhrec_rank=_parse_int(get_val("edhrecRank")), + layout=_clean_str(get_val("layout")) or "normal", + side=_clean_str(get_val("side")) or None, ) @@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str: def _parse_literal_list(value: object) -> list[str]: if value is None: return [] - if isinstance(value, (list, tuple, set)): + + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + # Handle lists, tuples, sets, and numpy arrays + if isinstance(value, (list, tuple, set)) or is_numpy: return [str(item).strip() for item in value if str(item).strip()] + text = str(value).strip() if not text: return [] @@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]: def _split_list(value: object) -> list[str]: + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + if isinstance(value, (list, tuple, set)) or is_numpy: + return [str(item).strip() for item in value if str(item).strip()] + text = _clean_str(value) if not text: return [] @@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]: def _parse_color_list(value: object) -> Tuple[str, ...]: + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + if isinstance(value, (list, tuple, set)) or is_numpy: + parts = [str(item).strip().upper() for item in value if str(item).strip()] + return tuple(parts) + text = _clean_str(value) if not text: return tuple() diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 50d899e..a7eadd7 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -95,7 +95,7 @@ class DeckBuilder( # If a seed was assigned pre-init, use it if self.seed is not None: # Import here to avoid any heavy import cycles at module import time - from random_util import set_seed as _set_seed # type: ignore + from random_util import set_seed as _set_seed self._rng = _set_seed(int(self.seed)) else: self._rng = random.Random() @@ -107,7 +107,7 @@ class DeckBuilder( def set_seed(self, seed: int | str) -> None: """Set deterministic seed for this builder and reset its RNG instance.""" try: - from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore + from random_util import derive_seed_from_string as _derive, set_seed as _set_seed s = _derive(seed) self.seed = int(s) self._rng = _set_seed(s) @@ -215,7 +215,7 @@ class DeckBuilder( try: # Compute a quick compliance snapshot here to hint at upcoming enforcement if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False): - from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore + from deck_builder.brackets_compliance import evaluate_deck as _eval bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower() commander = getattr(self, 'commander_name', None) snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key) @@ -240,15 +240,15 @@ class DeckBuilder( csv_path = self.export_decklist_csv() # Persist CSV path immediately (before any later potential exceptions) try: - self.last_csv_path = csv_path # type: ignore[attr-defined] + self.last_csv_path = csv_path except Exception: pass try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(csv_path)) - txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] + txt_path = self.export_decklist_text(filename=base + '.txt') try: - self.last_txt_path = txt_path # type: ignore[attr-defined] + self.last_txt_path = txt_path except Exception: pass # Display the text file contents for easy copy/paste to online deck builders @@ -256,18 +256,18 @@ class DeckBuilder( # Compute bracket compliance and save a JSON report alongside exports try: if hasattr(self, 'compute_and_print_compliance'): - report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + report0 = self.compute_and_print_compliance(base_stem=base) # If non-compliant and interactive, offer enforcement now try: if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False): - from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore + from deck_builder.phases.phase6_reporting import ReportingMixin as _RM if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'): self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.") try: _ = self.input_func("") except Exception: pass - self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined] + self.enforce_and_reexport(base_stem=base, mode='prompt') except Exception: pass except Exception: @@ -295,12 +295,12 @@ class DeckBuilder( cfg_dir = 'config' if cfg_dir: _os.makedirs(cfg_dir, exist_ok=True) - self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined] + self.export_run_config_json(directory=cfg_dir, filename=base + '.json') if cfg_path_env: cfg_dir2 = _os.path.dirname(cfg_path_env) or '.' cfg_name2 = _os.path.basename(cfg_path_env) _os.makedirs(cfg_dir2, exist_ok=True) - self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined] + self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) except Exception: pass except Exception: @@ -308,8 +308,8 @@ class DeckBuilder( else: # Mark suppression so random flow knows nothing was exported yet try: - self.last_csv_path = None # type: ignore[attr-defined] - self.last_txt_path = None # type: ignore[attr-defined] + self.last_csv_path = None + self.last_txt_path = None except Exception: pass # If owned-only and deck not complete, print a note @@ -624,8 +624,8 @@ class DeckBuilder( try: rec.card_library = rec_subset # Export CSV and TXT with suffix - rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) # type: ignore[attr-defined] - rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) # type: ignore[attr-defined] + rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) + rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) finally: rec.card_library = original_lib # Notify user succinctly @@ -1843,7 +1843,7 @@ class DeckBuilder( from deck_builder import builder_constants as bc from settings import MULTIPLE_COPY_CARDS except Exception: - MULTIPLE_COPY_CARDS = [] # type: ignore + MULTIPLE_COPY_CARDS = [] is_land = 'land' in str(card_type or entry.get('Card Type','')).lower() is_basic = False try: @@ -2353,7 +2353,7 @@ class DeckBuilder( rng = getattr(self, 'rng', None) try: if rng: - rng.shuffle(bucket_keys) # type: ignore + rng.shuffle(bucket_keys) else: random.shuffle(bucket_keys) except Exception: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index dd664d3..02e2054 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any +from typing import Dict, List, Final, Tuple, Union, Callable, Any from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified from path_util import csv_dir import pandas as pd @@ -21,7 +21,7 @@ DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}' COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv" DECK_DIRECTORY = '../deck_files' # M4: Deprecated - Parquet handles types natively (no converters needed) -COMMANDER_CONVERTERS: Final[Dict[str, str]] = { +COMMANDER_CONVERTERS: Final[Dict[str, Any]] = { 'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval, 'roleTags': ast.literal_eval, @@ -140,18 +140,18 @@ OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { } # Card category validation rules -CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { +CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { 'power': {'type': ('str', 'int', 'float'), 'required': True}, 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, 'creatureTypes': {'type': 'list', 'required': True} } -SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { +SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { 'manaCost': {'type': 'str', 'required': True}, 'text': {'type': 'str', 'required': True} } -LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { +LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { 'type': {'type': ('str', 'object'), 'required': True}, 'text': {'type': ('str', 'object'), 'required': False} } @@ -526,7 +526,7 @@ CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch # CSV validation configuration -CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = { +CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { 'name': {'type': ('str', 'object'), 'required': True, 'unique': True}, 'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000}, 'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20}, @@ -602,12 +602,12 @@ GAME_CHANGERS: Final[List[str]] = [ # - color_identity: list[str] of required color letters (subset must be in commander CI) # - printed_cap: int | None (None means no printed cap) # - exclusive_group: str | None (at most one from the same group) -# - triggers: { tags_any: list[str], tags_all: list[str] } +# - triggers: { tagsAny: list[str], tags_all: list[str] } # - default_count: int (default 25) # - rec_window: tuple[int,int] (recommendation window) # - thrumming_stone_synergy: bool # - type_hint: 'creature' | 'noncreature' -MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { +MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'cid_timeless_artificer': { 'id': 'cid_timeless_artificer', 'name': 'Cid, Timeless Artificer', @@ -615,7 +615,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['artificer kindred', 'hero kindred', 'artifacts matter'], + 'tagsAny': ['artificer kindred', 'hero kindred', 'artifacts matter'], 'tags_all': [] }, 'default_count': 25, @@ -630,7 +630,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], + 'tagsAny': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -645,7 +645,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['rabbit kindred','tokens matter','aggro'], + 'tagsAny': ['rabbit kindred','tokens matter','aggro'], 'tags_all': [] }, 'default_count': 25, @@ -660,7 +660,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], + 'tagsAny': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], 'tags_all': [] }, 'default_count': 25, @@ -675,7 +675,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': 'rats', 'triggers': { - 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -690,7 +690,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': 'rats', 'triggers': { - 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -705,7 +705,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': 7, 'exclusive_group': None, 'triggers': { - 'tags_any': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], + 'tagsAny': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], 'tags_all': [] }, 'default_count': 7, @@ -720,7 +720,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['mill','advisor kindred','control','defenders','walls','draw-go'], + 'tagsAny': ['mill','advisor kindred','control','defenders','walls','draw-go'], 'tags_all': [] }, 'default_count': 25, @@ -735,7 +735,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], + 'tagsAny': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], 'tags_all': [] }, 'default_count': 25, @@ -750,7 +750,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': 9, 'exclusive_group': None, 'triggers': { - 'tags_any': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], + 'tagsAny': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], 'tags_all': [] }, 'default_count': 9, @@ -765,7 +765,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['bird kindred','aggro'], + 'tagsAny': ['bird kindred','aggro'], 'tags_all': [] }, 'default_count': 25, @@ -780,7 +780,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], + 'tagsAny': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], 'tags_all': [] }, 'default_count': 25, @@ -956,3 +956,4 @@ def get_backgrounds(df: pd.DataFrame) -> pd.DataFrame: if 'isBackground' not in df.columns: return pd.DataFrame() return df[df['isBackground'] == True].copy() # noqa: E712 + diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 36ab3fe..a47101e 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool: return False +def _extract_colors_from_land_type(type_line: str) -> List[str]: + """Extract mana colors from basic land types in a type line. + + Args: + type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains") + + Returns: + List of color letters (e.g., ['R'], ['G', 'W']) + """ + if not isinstance(type_line, str): + return [] + type_lower = type_line.lower() + colors = [] + basic_land_colors = { + 'plains': 'W', + 'island': 'U', + 'swamp': 'B', + 'mountain': 'R', + 'forest': 'G', + } + for land_type, color in basic_land_colors.items(): + if land_type in type_lower: + colors.append(color) + return colors + + def _resolved_csv_dir(base_dir: str | None = None) -> str: try: if base_dir: @@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: return {} # Select only needed columns - usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName'] + # M9: Added backType to detect MDFC lands where land is on back face + # M9: Added colorIdentity to extract mana colors for MDFC lands + usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity'] available_cols = [col for col in usecols if col in df.columns] if not available_cols: return {} @@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: multi_df['type'] = multi_df['type'].fillna('').astype(str) multi_df['side'] = multi_df['side'].fillna('').astype(str) multi_df['text'] = multi_df['text'].fillna('').astype(str) - land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] + # M9: Check both type and backType for land faces + if 'backType' in multi_df.columns: + multi_df['backType'] = multi_df['backType'].fillna('').astype(str) + land_mask = ( + multi_df['type'].str.contains('land', case=False, na=False) | + multi_df['backType'].str.contains('land', case=False, na=False) + ) + land_rows = multi_df[land_mask] + else: + land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] if land_rows.empty: return {} mapping: Dict[str, Dict[str, Any]] = {} @@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: seen: set[tuple[str, str, str]] = set() front_is_land = False layout_val = '' + + # M9: Handle merged rows with backType + if len(group) == 1 and 'backType' in group.columns: + row = group.iloc[0] + back_type_val = str(row.get('backType', '') or '') + if back_type_val and 'land' in back_type_val.lower(): + # Construct synthetic faces from merged row + front_type = str(row.get('type', '') or '') + front_text = str(row.get('text', '') or '') + mana_cost_val = str(row.get('manaCost', '') or '') + mana_value_raw = row.get('manaValue', '') + mana_value_val = None + try: + if mana_value_raw not in (None, ''): + mana_value_val = float(mana_value_raw) + if math.isnan(mana_value_val): + mana_value_val = None + except Exception: + mana_value_val = None + + # Front face + faces.append({ + 'face': str(row.get('faceName', '') or name), + 'side': 'a', + 'type': front_type, + 'text': front_text, + 'mana_cost': mana_cost_val, + 'mana_value': mana_value_val, + 'produces_mana': _detect_produces_mana(front_text), + 'is_land': 'land' in front_type.lower(), + 'layout': str(row.get('layout', '') or ''), + }) + + # Back face (synthesized) + # M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line) + color_identity_raw = row.get('colorIdentity', []) + if isinstance(color_identity_raw, str): + # Handle string format like "['G']" or "G" + try: + import ast + color_identity_raw = ast.literal_eval(color_identity_raw) + except Exception: + color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()] + back_face_colors = list(color_identity_raw) if color_identity_raw else [] + # Fallback to parsing land type if colorIdentity not available + if not back_face_colors: + back_face_colors = _extract_colors_from_land_type(back_type_val) + + faces.append({ + 'face': name.split(' // ')[1] if ' // ' in name else 'Back', + 'side': 'b', + 'type': back_type_val, + 'text': '', # Not available in merged row + 'mana_cost': '', + 'mana_value': None, + 'produces_mana': True, # Assume land produces mana + 'is_land': True, + 'layout': str(row.get('layout', '') or ''), + 'colors': back_face_colors, # M9: Color information for mana sources + }) + + front_is_land = 'land' in front_type.lower() + layout_val = str(row.get('layout', '') or '') + mapping[name] = { + 'faces': faces, + 'front_is_land': front_is_land, + 'layout': layout_val, + 'colors': back_face_colors, # M9: Store colors at top level for easy access + } + continue + + # Original logic for multi-row format for _, row in group.iterrows(): side_raw = str(row.get('side', '') or '').strip() side_key = side_raw.lower() @@ -316,7 +425,7 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ matrix: Dict[str, Dict[str, int]] = {} lookup = {} if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns: - for _, r in full_df.iterrows(): # type: ignore[attr-defined] + for _, r in full_df.iterrows(): nm = str(r.get('name', '')) if nm and nm not in lookup: lookup[nm] = r @@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if hasattr(row, 'get'): row_type_raw = row.get('type', row.get('type_line', '')) or '' tline_full = str(row_type_raw).lower() + # M9: Check backType for MDFC land detection + back_type_raw = '' + if hasattr(row, 'get'): + back_type_raw = row.get('backType', '') or '' + back_type = str(back_type_raw).lower() # Land or permanent that could produce mana via text - is_land = ('land' in entry_type) or ('land' in tline_full) + is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type) base_is_land = is_land text_field_raw = '' if hasattr(row, 'get'): @@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if face_types or face_texts: is_land = True text_field = text_field_raw.lower().replace('\n', ' ') - # Skip obvious non-permanents (rituals etc.) + # Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land + # M9: If is_land is True (from backType check), we keep it regardless of front face type if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full): continue # Keep only candidates that are lands OR whose text indicates mana production @@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ colors['_dfc_land'] = True if not (base_is_land or dfc_entry.get('front_is_land')): colors['_dfc_counts_as_extra'] = True + # M9: Extract colors from DFC face metadata (back face land colors) + dfc_colors = dfc_entry.get('colors', []) + if dfc_colors: + for color in dfc_colors: + if color in colors: + colors[color] = 1 produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C')) if produces_any_color or colors.get('_dfc_land'): matrix[name] = colors @@ -729,7 +850,7 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i out: list[tuple[int,str,str,str]] = [] if df is None or getattr(df, 'empty', True): return out - for _, row in df.iterrows(): # type: ignore[attr-defined] + for _, row in df.iterrows(): try: name = str(row.get('name','')) if not name or name in already or name in basics: @@ -993,7 +1114,7 @@ def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'): # --------------------------------------------------------------------------- # Tag-driven land suggestion helpers # --------------------------------------------------------------------------- -def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override] +def build_tag_driven_suggestions(builder) -> list[dict]: """Return a list of suggestion dicts based on selected commander tags. Each dict fields: @@ -1081,7 +1202,7 @@ def color_balance_addition_candidates(builder, target_color: str, combined_df) - return [] existing = set(builder.card_library.keys()) out: list[tuple[str, int]] = [] - for _, row in combined_df.iterrows(): # type: ignore[attr-defined] + for _, row in combined_df.iterrows(): name = str(row.get('name', '')) if not name or name in existing or any(name == o[0] for o in out): continue diff --git a/code/deck_builder/enforcement.py b/code/deck_builder/enforcement.py index 0f0ef17..ecc9395 100644 --- a/code/deck_builder/enforcement.py +++ b/code/deck_builder/enforcement.py @@ -88,12 +88,12 @@ def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]: # Sort by edhrecRank then manaValue try: from . import builder_utils as bu - sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined] + sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # Prefer-owned bias if getattr(builder, "prefer_owned", False): owned = getattr(builder, "owned_card_names", None) if owned: - sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined] + sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) except Exception: sorted_df = pool @@ -363,7 +363,7 @@ def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict: break # Rank candidates: break the most combos first; break ties by worst desirability cand_names = list(freq.keys()) - cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type] + cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) removed_any = False for nm in cand_names: if nm in blocked: diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py index f5808bc..4ec59fc 100644 --- a/code/deck_builder/partner_selection.py +++ b/code/deck_builder/partner_selection.py @@ -17,7 +17,7 @@ from logging_util import get_logger logger = get_logger(__name__) try: # Optional pandas import for type checking without heavy dependency at runtime. - import pandas as _pd # type: ignore + import pandas as _pd except Exception: # pragma: no cover - tests provide DataFrame-like objects. _pd = None # type: ignore @@ -267,7 +267,7 @@ def _find_commander_row(df: Any, name: str | None): if not target: return None - if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore + if _pd is not None and isinstance(df, _pd.DataFrame): columns = [col for col in ("name", "faceName") if col in df.columns] for col in columns: series = df[col].astype(str).str.casefold() @@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]: def _normalize_string_sequence(value: Any) -> tuple[str, ...]: if value is None: return tuple() - if isinstance(value, (list, tuple, set)): + # Handle numpy arrays, lists, tuples, sets, and other sequences + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + is_numpy = False + + if isinstance(value, (list, tuple, set)) or is_numpy: items = list(value) else: text = _safe_str(value) diff --git a/code/deck_builder/phases/phase0_core.py b/code/deck_builder/phases/phase0_core.py index d464204..a23f96c 100644 --- a/code/deck_builder/phases/phase0_core.py +++ b/code/deck_builder/phases/phase0_core.py @@ -25,11 +25,11 @@ No behavior change intended. # Attempt to use a fast fuzzy library; fall back gracefully try: - from rapidfuzz import process as rf_process, fuzz as rf_fuzz # type: ignore + from rapidfuzz import process as rf_process, fuzz as rf_fuzz _FUZZ_BACKEND = "rapidfuzz" except ImportError: # pragma: no cover - environment dependent try: - from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz # type: ignore + from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz _FUZZ_BACKEND = "fuzzywuzzy" except ImportError: # pragma: no cover _FUZZ_BACKEND = "difflib" diff --git a/code/deck_builder/phases/phase1_commander.py b/code/deck_builder/phases/phase1_commander.py index 98f196c..6cdead5 100644 --- a/code/deck_builder/phases/phase1_commander.py +++ b/code/deck_builder/phases/phase1_commander.py @@ -68,7 +68,7 @@ class CommanderSelectionMixin: out_words[0] = out_words[0][:1].upper() + out_words[0][1:] return ' '.join(out_words) - def choose_commander(self) -> str: # type: ignore[override] + def choose_commander(self) -> str: df = self.load_commander_data() names = df["name"].tolist() while True: @@ -113,7 +113,7 @@ class CommanderSelectionMixin: continue query = self._normalize_commander_query(choice) # treat as new (normalized) query - def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override] + def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: row = df[df["name"] == name].iloc[0] pretty = self._format_commander_pretty(row) self.output_func("\n" + pretty) @@ -126,7 +126,7 @@ class CommanderSelectionMixin: return False self.output_func("Please enter y or n.") - def _apply_commander_selection(self, row: pd.Series): # type: ignore[override] + def _apply_commander_selection(self, row: pd.Series): self.commander_name = row["name"] self.commander_row = row tags_value = row.get("themeTags", []) @@ -136,7 +136,7 @@ class CommanderSelectionMixin: # --------------------------- # Tag Prioritization # --------------------------- - def select_commander_tags(self) -> List[str]: # type: ignore[override] + def select_commander_tags(self) -> List[str]: if not self.commander_name: self.output_func("No commander chosen yet. Selecting commander first...") self.choose_commander() @@ -173,7 +173,7 @@ class CommanderSelectionMixin: self._update_commander_dict_with_selected_tags() return self.selected_tags - def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override] + def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: while True: self.output_func("\nCurrent options:") for i, t in enumerate(available, 1): @@ -192,7 +192,7 @@ class CommanderSelectionMixin: return matches[0] self.output_func("Invalid selection. Try again.") - def _update_commander_dict_with_selected_tags(self): # type: ignore[override] + def _update_commander_dict_with_selected_tags(self): if not self.commander_dict and self.commander_row is not None: self._initialize_commander_dict(self.commander_row) if not self.commander_dict: @@ -205,7 +205,7 @@ class CommanderSelectionMixin: # --------------------------- # Power Bracket Selection # --------------------------- - def select_power_bracket(self) -> BracketDefinition: # type: ignore[override] + def select_power_bracket(self) -> BracketDefinition: if self.bracket_definition: return self.bracket_definition self.output_func("\nChoose Deck Power Bracket:") @@ -229,14 +229,14 @@ class CommanderSelectionMixin: return match self.output_func("Invalid input. Type 1-5 or 'info'.") - def _print_bracket_details(self): # type: ignore[override] + def _print_bracket_details(self): self.output_func("\nBracket Details:") for bd in BRACKET_DEFINITIONS: self.output_func(f"\n[{bd.level}] {bd.name}") self.output_func(bd.long_desc) self.output_func(self._format_limits(bd.limits)) - def _print_selected_bracket_summary(self): # type: ignore[override] + def _print_selected_bracket_summary(self): self.output_func("\nBracket Constraints:") if self.bracket_limits: self.output_func(self._format_limits(self.bracket_limits)) diff --git a/code/deck_builder/phases/phase2_lands_basics.py b/code/deck_builder/phases/phase2_lands_basics.py index ccf0a3f..36b1586 100644 --- a/code/deck_builder/phases/phase2_lands_basics.py +++ b/code/deck_builder/phases/phase2_lands_basics.py @@ -22,7 +22,7 @@ Expected attributes / methods on the host DeckBuilder: class LandBasicsMixin: - def add_basic_lands(self): # type: ignore[override] + def add_basic_lands(self): """Add basic (or snow basic) lands based on color identity. Logic: @@ -71,8 +71,8 @@ class LandBasicsMixin: basic_min: Optional[int] = None land_total: Optional[int] = None if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - basic_min = self.ideal_counts.get('basic_lands') # type: ignore[attr-defined] - land_total = self.ideal_counts.get('lands') # type: ignore[attr-defined] + basic_min = self.ideal_counts.get('basic_lands') + land_total = self.ideal_counts.get('lands') if basic_min is None: basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if land_total is None: @@ -136,7 +136,7 @@ class LandBasicsMixin: self.output_func(f" {name.ljust(width)} : {cnt}") self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})") - def run_land_step1(self): # type: ignore[override] + def run_land_step1(self): """Public wrapper to execute land building step 1 (basics).""" self.add_basic_lands() try: diff --git a/code/deck_builder/phases/phase2_lands_duals.py b/code/deck_builder/phases/phase2_lands_duals.py index 7db15f2..713c1f4 100644 --- a/code/deck_builder/phases/phase2_lands_duals.py +++ b/code/deck_builder/phases/phase2_lands_duals.py @@ -21,7 +21,7 @@ Host DeckBuilder must provide: """ class LandDualsMixin: - def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override] + def add_dual_lands(self, requested_count: int | None = None): """Add two-color 'typed' dual lands based on color identity.""" if not getattr(self, 'files_to_load', []): try: @@ -117,10 +117,10 @@ class LandDualsMixin: pair_buckets[key] = names min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if getattr(self, 'ideal_counts', None): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] - basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) + basic_floor = self._basic_floor(min_basic_cfg) default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6) - remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] + remaining_capacity = max(0, land_target - self._current_land_count()) effective_default = min(default_dual_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool)) desired = effective_default if requested_count is None else max(0, int(requested_count)) if desired == 0: @@ -129,14 +129,14 @@ class LandDualsMixin: if remaining_capacity == 0 and desired > 0: slots_needed = desired freed_slots = 0 - while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: # type: ignore[attr-defined] - target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] - if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] + while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: + target_basic = self._choose_basic_to_trim() + if not target_basic or not self._decrement_card(target_basic): break freed_slots += 1 if freed_slots == 0: desired = 0 - remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] + remaining_capacity = max(0, land_target - self._current_land_count()) desired = min(desired, remaining_capacity, len(pool)) if desired <= 0: self.output_func("Dual Lands: No capacity after trimming; skipping.") @@ -146,7 +146,7 @@ class LandDualsMixin: rng = getattr(self, 'rng', None) try: if rng: - rng.shuffle(bucket_keys) # type: ignore + rng.shuffle(bucket_keys) else: random.shuffle(bucket_keys) except Exception: @@ -171,7 +171,7 @@ class LandDualsMixin: break added: List[str] = [] for name in chosen: - if self._current_land_count() >= land_target: # type: ignore[attr-defined] + if self._current_land_count() >= land_target: break # Determine sub_role as concatenated color pair for traceability try: @@ -198,7 +198,7 @@ class LandDualsMixin: role='dual', sub_role=sub_role, added_by='lands_step5' - ) # type: ignore[attr-defined] + ) added.append(name) self.output_func("\nDual Lands Added (Step 5):") if not added: @@ -207,11 +207,11 @@ class LandDualsMixin: width = max(len(n) for n in added) for n in added: self.output_func(f" {n.ljust(width)} : 1") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") - def run_land_step5(self, requested_count: int | None = None): # type: ignore[override] + def run_land_step5(self, requested_count: int | None = None): self.add_dual_lands(requested_count=requested_count) - self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined] + self._enforce_land_cap(step_label="Duals (Step 5)") try: from .. import builder_utils as _bu _bu.export_current_land_pool(self, '5') diff --git a/code/deck_builder/phases/phase2_lands_fetch.py b/code/deck_builder/phases/phase2_lands_fetch.py index 57de480..4dcf54b 100644 --- a/code/deck_builder/phases/phase2_lands_fetch.py +++ b/code/deck_builder/phases/phase2_lands_fetch.py @@ -19,7 +19,7 @@ Host DeckBuilder must supply: """ class LandFetchMixin: - def add_fetch_lands(self, requested_count: int | None = None): # type: ignore[override] + def add_fetch_lands(self, requested_count: int | None = None): """Add fetch lands (color-specific + generic) respecting land target.""" if not getattr(self, 'files_to_load', []): try: @@ -28,8 +28,8 @@ class LandFetchMixin: except Exception as e: # pragma: no cover - defensive self.output_func(f"Cannot add fetch lands until color identity resolved: {e}") return - land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) # type: ignore[attr-defined] - current = self._current_land_count() # type: ignore[attr-defined] + land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) + current = self._current_land_count() color_order = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']] color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) candidates: List[str] = [] @@ -56,7 +56,7 @@ class LandFetchMixin: self.output_func("\nAdd Fetch Lands (Step 4):") self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.") prompt = f"Enter desired number of fetch lands (default: {effective_default}):" - desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) # type: ignore[attr-defined] + desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) else: desired = max(0, int(requested_count)) if desired > remaining_fetch_slots: @@ -70,20 +70,20 @@ class LandFetchMixin: if remaining_capacity == 0 and desired > 0: min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if getattr(self, 'ideal_counts', None): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] - floor_basics = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) + floor_basics = self._basic_floor(min_basic_cfg) slots_needed = desired - while slots_needed > 0 and self._count_basic_lands() > floor_basics: # type: ignore[attr-defined] - target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] - if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] + while slots_needed > 0 and self._count_basic_lands() > floor_basics: + target_basic = self._choose_basic_to_trim() + if not target_basic or not self._decrement_card(target_basic): break slots_needed -= 1 - remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] + remaining_capacity = max(0, land_target - self._current_land_count()) if remaining_capacity > 0 and slots_needed == 0: break if slots_needed > 0 and remaining_capacity == 0: desired -= slots_needed - remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] + remaining_capacity = max(0, land_target - self._current_land_count()) desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots) if desired <= 0: self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.") @@ -101,7 +101,7 @@ class LandFetchMixin: if k >= len(pool): return pool.copy() try: - return (rng.sample if rng else random.sample)(pool, k) # type: ignore + return (rng.sample if rng else random.sample)(pool, k) except Exception: return pool[:k] need = desired @@ -117,7 +117,7 @@ class LandFetchMixin: added: List[str] = [] for nm in chosen: - if self._current_land_count() >= land_target: # type: ignore[attr-defined] + if self._current_land_count() >= land_target: break note = 'generic' if nm in generic_list else 'color-specific' self.add_card( @@ -126,11 +126,11 @@ class LandFetchMixin: role='fetch', sub_role=note, added_by='lands_step4' - ) # type: ignore[attr-defined] + ) added.append(nm) # Record actual number of fetch lands added for export/replay context try: - setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined] + setattr(self, 'fetch_count', len(added)) except Exception: pass self.output_func("\nFetch Lands Added (Step 4):") @@ -141,9 +141,9 @@ class LandFetchMixin: for n in added: note = 'generic' if n in generic_list else 'color-specific' self.output_func(f" {n.ljust(width)} : 1 ({note})") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") - def run_land_step4(self, requested_count: int | None = None): # type: ignore[override] + def run_land_step4(self, requested_count: int | None = None): """Public wrapper to add fetch lands. If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds. @@ -155,7 +155,7 @@ class LandFetchMixin: except Exception: desired = requested_count self.add_fetch_lands(requested_count=desired) - self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined] + self._enforce_land_cap(step_label="Fetch (Step 4)") try: from .. import builder_utils as _bu _bu.export_current_land_pool(self, '4') diff --git a/code/deck_builder/phases/phase2_lands_kindred.py b/code/deck_builder/phases/phase2_lands_kindred.py index bca1827..2b361c7 100644 --- a/code/deck_builder/phases/phase2_lands_kindred.py +++ b/code/deck_builder/phases/phase2_lands_kindred.py @@ -20,7 +20,7 @@ Host DeckBuilder must provide: """ class LandKindredMixin: - def add_kindred_lands(self): # type: ignore[override] + def add_kindred_lands(self): """Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'. Baseline inclusions on kindred focus: @@ -41,32 +41,32 @@ class LandKindredMixin: self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.") return if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) # type: ignore[attr-defined] + land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) else: land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] - basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) + basic_floor = self._basic_floor(min_basic_cfg) def ensure_capacity() -> bool: - if self._current_land_count() < land_target: # type: ignore[attr-defined] + if self._current_land_count() < land_target: return True - if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] + if self._count_basic_lands() <= basic_floor: return False - target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] + target_basic = self._choose_basic_to_trim() if not target_basic: return False - if not self._decrement_card(target_basic): # type: ignore[attr-defined] + if not self._decrement_card(target_basic): return False - return self._current_land_count() < land_target # type: ignore[attr-defined] + return self._current_land_count() < land_target colors = getattr(self, 'color_identity', []) or [] added: List[str] = [] reasons: Dict[str, str] = {} def try_add(name: str, reason: str): - if name in self.card_library: # type: ignore[attr-defined] + if name in self.card_library: return if not ensure_capacity(): return @@ -77,7 +77,7 @@ class LandKindredMixin: sub_role='baseline' if reason.startswith('kindred focus') else 'tribe-specific', added_by='lands_step3', trigger_tag='Kindred/Tribal' - ) # type: ignore[attr-defined] + ) added.append(name) reasons[name] = reason @@ -105,14 +105,14 @@ class LandKindredMixin: if snapshot is not None and not snapshot.empty and tribe_terms: dynamic_limit = 5 for tribe in sorted(tribe_terms): - if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] + if self._current_land_count() >= land_target or dynamic_limit <= 0: break tribe_lower = tribe.lower() matches: List[str] = [] for _, row in snapshot.iterrows(): try: nm = str(row.get('name', '')) - if not nm or nm in self.card_library: # type: ignore[attr-defined] + if not nm or nm in self.card_library: continue tline = str(row.get('type', row.get('type_line', ''))).lower() if 'land' not in tline: @@ -125,7 +125,7 @@ class LandKindredMixin: except Exception: continue for nm in matches[:2]: - if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] + if self._current_land_count() >= land_target or dynamic_limit <= 0: break if nm in added or nm in getattr(bc, 'BASIC_LANDS', []): continue @@ -139,12 +139,12 @@ class LandKindredMixin: width = max(len(n) for n in added) for n in added: self.output_func(f" {n.ljust(width)} : 1 ({reasons.get(n,'')})") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") - def run_land_step3(self): # type: ignore[override] + def run_land_step3(self): """Public wrapper to add kindred-focused lands.""" self.add_kindred_lands() - self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined] + self._enforce_land_cap(step_label="Kindred (Step 3)") try: from .. import builder_utils as _bu _bu.export_current_land_pool(self, '3') diff --git a/code/deck_builder/phases/phase2_lands_misc.py b/code/deck_builder/phases/phase2_lands_misc.py index a12ce0d..4d0cbef 100644 --- a/code/deck_builder/phases/phase2_lands_misc.py +++ b/code/deck_builder/phases/phase2_lands_misc.py @@ -19,7 +19,7 @@ class LandMiscUtilityMixin: - Diagnostics & CSV exports """ - def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override] + def add_misc_utility_lands(self, requested_count: Optional[int] = None): # --- Initialization & candidate collection --- if not getattr(self, 'files_to_load', None): try: @@ -293,7 +293,7 @@ class LandMiscUtilityMixin: if getattr(self, 'show_diagnostics', False) and filtered_out: self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})") - def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override] + def run_land_step7(self, requested_count: Optional[int] = None): self.add_misc_utility_lands(requested_count=requested_count) self._enforce_land_cap(step_label="Utility (Step 7)") self._build_tag_driven_land_suggestions() @@ -305,12 +305,12 @@ class LandMiscUtilityMixin: pass # ---- Tag-driven suggestion helpers (used after Step 7) ---- - def _build_tag_driven_land_suggestions(self): # type: ignore[override] + def _build_tag_driven_land_suggestions(self): suggestions = bu.build_tag_driven_suggestions(self) if suggestions: self.suggested_lands_queue.extend(suggestions) - def _apply_land_suggestions_if_room(self): # type: ignore[override] + def _apply_land_suggestions_if_room(self): if not self.suggested_lands_queue: return land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35) diff --git a/code/deck_builder/phases/phase2_lands_optimize.py b/code/deck_builder/phases/phase2_lands_optimize.py index c74d411..9c32129 100644 --- a/code/deck_builder/phases/phase2_lands_optimize.py +++ b/code/deck_builder/phases/phase2_lands_optimize.py @@ -12,7 +12,7 @@ class LandOptimizationMixin: Provides optimize_tapped_lands and run_land_step8 (moved from monolithic builder). """ - def optimize_tapped_lands(self): # type: ignore[override] + def optimize_tapped_lands(self): df = getattr(self, '_combined_cards_df', None) if df is None or df.empty: return @@ -146,7 +146,7 @@ class LandOptimizationMixin: new_tapped += 1 self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})") - def run_land_step8(self): # type: ignore[override] + def run_land_step8(self): self.optimize_tapped_lands() self._enforce_land_cap(step_label="Tapped Opt (Step 8)") if self.color_source_matrix_baseline is None: diff --git a/code/deck_builder/phases/phase2_lands_staples.py b/code/deck_builder/phases/phase2_lands_staples.py index 8d2e21c..159319c 100644 --- a/code/deck_builder/phases/phase2_lands_staples.py +++ b/code/deck_builder/phases/phase2_lands_staples.py @@ -27,10 +27,10 @@ class LandStaplesMixin: # --------------------------- # Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet) # --------------------------- - def _current_land_count(self) -> int: # type: ignore[override] + def _current_land_count(self) -> int: """Return total number of land cards currently in the library (counts duplicates).""" total = 0 - for name, entry in self.card_library.items(): # type: ignore[attr-defined] + for name, entry in self.card_library.items(): ctype = entry.get('Card Type', '') if ctype and 'land' in ctype.lower(): total += entry.get('Count', 1) @@ -47,7 +47,7 @@ class LandStaplesMixin: continue return total - def add_staple_lands(self): # type: ignore[override] + def add_staple_lands(self): """Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands). Respects total land target (ideal_counts['lands']). Skips additions once target reached. @@ -62,25 +62,25 @@ class LandStaplesMixin: return land_target = None if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - land_target = self.ideal_counts.get('lands') # type: ignore[attr-defined] + land_target = self.ideal_counts.get('lands') if land_target is None: land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] - basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) + basic_floor = self._basic_floor(min_basic_cfg) def ensure_capacity() -> bool: - if self._current_land_count() < land_target: # type: ignore[attr-defined] + if self._current_land_count() < land_target: return True - if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] + if self._count_basic_lands() <= basic_floor: return False - target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] + target_basic = self._choose_basic_to_trim() if not target_basic: return False - if not self._decrement_card(target_basic): # type: ignore[attr-defined] + if not self._decrement_card(target_basic): return False - return self._current_land_count() < land_target # type: ignore[attr-defined] + return self._current_land_count() < land_target commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or []) colors = getattr(self, 'color_identity', []) or [] @@ -102,7 +102,7 @@ class LandStaplesMixin: if not ensure_capacity(): self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.") break - if land_name in self.card_library: # type: ignore[attr-defined] + if land_name in self.card_library: continue try: include = cond(list(commander_tags_all), colors, commander_power) @@ -115,7 +115,7 @@ class LandStaplesMixin: role='staple', sub_role='generic-staple', added_by='lands_step2' - ) # type: ignore[attr-defined] + ) added.append(land_name) if land_name == 'Command Tower': reasons[land_name] = f"multi-color ({len(colors)} colors)" @@ -137,12 +137,12 @@ class LandStaplesMixin: for n in added: reason = reasons.get(n, '') self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") - def run_land_step2(self): # type: ignore[override] + def run_land_step2(self): """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" self.add_staple_lands() - self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined] + self._enforce_land_cap(step_label="Staples (Step 2)") try: from .. import builder_utils as _bu _bu.export_current_land_pool(self, '2') diff --git a/code/deck_builder/phases/phase2_lands_triples.py b/code/deck_builder/phases/phase2_lands_triples.py index 97fbcd5..8c86bbc 100644 --- a/code/deck_builder/phases/phase2_lands_triples.py +++ b/code/deck_builder/phases/phase2_lands_triples.py @@ -59,7 +59,7 @@ class LandTripleMixin: 'forest': 'G', } - for _, row in df.iterrows(): # type: ignore + for _, row in df.iterrows(): try: name = str(row.get('name','')) if not name or name in self.card_library: diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index fe380af..e10b02c 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -33,7 +33,7 @@ class CreatureAdditionMixin: self.output_func("Card pool missing 'type' column; cannot add creatures.") return try: - context = self.get_theme_context() # type: ignore[attr-defined] + context = self.get_theme_context() except Exception: context = None if context is None or not getattr(context, 'ordered_targets', []): @@ -480,7 +480,7 @@ class CreatureAdditionMixin: drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst)) mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd # type: ignore + import pandas as _pd mask_keep = _pd.Series(mask_keep, index=df.index) except Exception: pass diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index 632806d..a0a0f90 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -78,7 +78,7 @@ class SpellAdditionMixin: # Combine into keep mask mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd # type: ignore + import pandas as _pd mask_keep = _pd.Series(mask_keep, index=df.index) except Exception: pass @@ -742,7 +742,7 @@ class SpellAdditionMixin: if df is None or df.empty or 'type' not in df.columns: return try: - context = self.get_theme_context() # type: ignore[attr-defined] + context = self.get_theme_context() except Exception: context = None if context is None or not getattr(context, 'ordered_targets', []): diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 97e691b..3044736 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -14,7 +14,7 @@ from ..shared_copy import build_land_headline, dfc_card_note logger = logging_util.logging.getLogger(__name__) try: - from prettytable import PrettyTable # type: ignore + from prettytable import PrettyTable except Exception: # pragma: no cover PrettyTable = None # type: ignore @@ -176,7 +176,7 @@ class ReportingMixin: """ try: # Lazy import to avoid cycles - from deck_builder.enforcement import enforce_bracket_compliance # type: ignore + from deck_builder.enforcement import enforce_bracket_compliance except Exception: self.output_func("Enforcement module unavailable.") return {} @@ -194,7 +194,7 @@ class ReportingMixin: if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'): before = int(total_cards) try: - self.fill_remaining_theme_spells() # type: ignore[attr-defined] + self.fill_remaining_theme_spells() except Exception: pass # Recompute after filler @@ -239,13 +239,13 @@ class ReportingMixin: csv_name = base_stem + ".csv" txt_name = base_stem + ".txt" # Overwrite exports with updated library - self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) # type: ignore[attr-defined] - self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # type: ignore[attr-defined] + self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) + self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # Re-export the JSON config to reflect any changes from enforcement json_name = base_stem + ".json" - self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # type: ignore[attr-defined] + self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # Recompute and write compliance next to them - self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined] + self.compute_and_print_compliance(base_stem=base_stem) # Inject enforcement details into the saved compliance JSON for UI transparency comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") try: @@ -259,18 +259,18 @@ class ReportingMixin: pass else: # Fall back to default export flow - csv_path = self.export_decklist_csv() # type: ignore[attr-defined] + csv_path = self.export_decklist_csv() try: base, _ = _os.path.splitext(csv_path) base_only = _os.path.basename(base) except Exception: base_only = None - self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined] + self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # Re-export JSON config after enforcement changes if base_only: - self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) # type: ignore[attr-defined] + self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) if base_only: - self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined] + self.compute_and_print_compliance(base_stem=base_only) # Inject enforcement into written JSON as above try: comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json") @@ -294,7 +294,7 @@ class ReportingMixin: """ try: # Late import to avoid circulars in some environments - from deck_builder.brackets_compliance import evaluate_deck # type: ignore + from deck_builder.brackets_compliance import evaluate_deck except Exception: self.output_func("Bracket compliance module unavailable.") return {} @@ -373,7 +373,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, any] = {} + row_lookup: Dict[str, Any] = {} if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -429,7 +429,7 @@ class ReportingMixin: # Surface land vs. MDFC counts for CLI users to mirror web summary copy try: - summary = self.build_deck_summary() # type: ignore[attr-defined] + summary = self.build_deck_summary() except Exception: summary = None if isinstance(summary, dict): @@ -483,9 +483,9 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, any] = {} + row_lookup: Dict[str, Any] = {} if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns: - for _, r in snapshot.iterrows(): # type: ignore[attr-defined] + for _, r in snapshot.iterrows(): nm = str(r.get('name')) if nm and nm not in row_lookup: row_lookup[nm] = r @@ -521,7 +521,7 @@ class ReportingMixin: builder_utils_module = None try: - from deck_builder import builder_utils as _builder_utils # type: ignore + from deck_builder import builder_utils as _builder_utils builder_utils_module = _builder_utils color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) except Exception: @@ -543,6 +543,9 @@ class ReportingMixin: mf_info = {} faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else [] layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None + # M9: If no colors found from mana production, try extracting from face metadata + if not card_colors and isinstance(mf_info, dict): + card_colors = list(mf_info.get('colors', [])) dfc_land_lookup[name] = { 'adds_extra_land': counts_as_extra, 'counts_as_land': not counts_as_extra, @@ -681,13 +684,14 @@ class ReportingMixin: 'faces': faces_meta, 'layout': layout_val, }) - if adds_extra: - dfc_extra_total += copies + # M9: Count ALL MDFC lands for land summary + dfc_extra_total += copies total_sources = sum(source_counts.values()) traditional_lands = type_counts.get('Land', 0) + # M9: dfc_extra_total now contains ALL MDFC lands, not just extras land_summary = { 'traditional': traditional_lands, - 'dfc_lands': dfc_extra_total, + 'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands 'with_dfc': traditional_lands + dfc_extra_total, 'dfc_cards': dfc_details, 'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total), @@ -852,7 +856,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, any] = {} + row_lookup: Dict[str, Any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -1124,7 +1128,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, any] = {} + row_lookup: Dict[str, Any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -1132,7 +1136,7 @@ class ReportingMixin: row_lookup[nm] = r try: - from deck_builder import builder_utils as _builder_utils # type: ignore + from deck_builder import builder_utils as _builder_utils color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} @@ -1383,3 +1387,4 @@ class ReportingMixin: """ # Card library printout suppressed; use CSV and text export for card list. pass + diff --git a/code/deck_builder/random_entrypoint.py b/code/deck_builder/random_entrypoint.py index 6f9526d..8b00d40 100644 --- a/code/deck_builder/random_entrypoint.py +++ b/code/deck_builder/random_entrypoint.py @@ -885,7 +885,7 @@ def _filter_multi(df: pd.DataFrame, primary: Optional[str], secondary: Optional[ if index_map is None: _ensure_theme_tag_index(current_df) index_map = current_df.attrs.get("_ltag_index") or {} - return index_map # type: ignore[return-value] + return index_map index_map_all = _get_index_map(df) @@ -1047,7 +1047,7 @@ def _check_constraints(candidate_count: int, constraints: Optional[Dict[str, Any if not constraints: return try: - req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined] + req_min = constraints.get("require_min_candidates") except Exception: req_min = None if req_min is None: @@ -1436,7 +1436,7 @@ def build_random_full_deck( primary_choice_idx, secondary_choice_idx, tertiary_choice_idx = _resolve_theme_choices_for_headless(base.commander, base) try: - from headless_runner import run as _run # type: ignore + from headless_runner import run as _run except Exception as e: return RandomFullBuildResult( seed=base.seed, @@ -1482,7 +1482,7 @@ def build_random_full_deck( summary: Dict[str, Any] | None = None try: if hasattr(builder, 'build_deck_summary'): - summary = builder.build_deck_summary() # type: ignore[attr-defined] + summary = builder.build_deck_summary() except Exception: summary = None @@ -1559,7 +1559,7 @@ def build_random_full_deck( if isinstance(custom_base, str) and custom_base.strip(): meta_payload["name"] = custom_base.strip() try: - commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined] + commander_meta = builder.get_commander_export_metadata() except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -1589,8 +1589,8 @@ def build_random_full_deck( try: import os as _os import json as _json - csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] - txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] + csv_path = getattr(builder, 'last_csv_path', None) + txt_path = getattr(builder, 'last_txt_path', None) if csv_path and isinstance(csv_path, str): base_path, _ = _os.path.splitext(csv_path) # If txt missing but expected, look for sibling @@ -1608,7 +1608,7 @@ def build_random_full_deck( # Compute compliance if not already saved try: if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) except Exception: compliance = None # Write summary sidecar if missing @@ -1646,7 +1646,7 @@ def build_random_full_deck( csv_path = existing_base base_path, _ = _os.path.splitext(csv_path) else: - tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined] + tmp_csv = builder.export_decklist_csv() stem_base, ext = _os.path.splitext(tmp_csv) if stem_base.endswith('_1'): original = stem_base[:-2] + ext @@ -1662,13 +1662,13 @@ def build_random_full_deck( if _os.path.isfile(target_txt): txt_path = target_txt else: - tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined] + tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt): txt_path = target_txt else: txt_path = tmp_txt if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) if summary: sidecar = base_path + '.summary.json' if not _os.path.isfile(sidecar): diff --git a/code/deck_builder/summary_telemetry.py b/code/deck_builder/summary_telemetry.py index 6afa02c..3bd38a3 100644 --- a/code/deck_builder/summary_telemetry.py +++ b/code/deck_builder/summary_telemetry.py @@ -167,7 +167,7 @@ def _reset_metrics_for_test() -> None: def _sanitize_theme_list(values: Iterable[Any]) -> list[str]: sanitized: list[str] = [] seen: set[str] = set() - for raw in values or []: # type: ignore[arg-type] + for raw in values or []: text = str(raw or "").strip() if not text: continue diff --git a/code/deck_builder/theme_catalog_loader.py b/code/deck_builder/theme_catalog_loader.py index c4d20ac..7d1214b 100644 --- a/code/deck_builder/theme_catalog_loader.py +++ b/code/deck_builder/theme_catalog_loader.py @@ -183,7 +183,7 @@ def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]: try: from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path except ImportError: # pragma: no cover - fallback when running as package - from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore + from code.type_definitions_theme_catalog import ThemeCatalog try: catalog = ThemeCatalog.model_validate(payload) diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py new file mode 100644 index 0000000..08a7c22 --- /dev/null +++ b/code/file_setup/image_cache.py @@ -0,0 +1,567 @@ +""" +Card image caching system. + +Downloads and manages local cache of Magic: The Gathering card images +from Scryfall, with graceful fallback to API when images are missing. + +Features: +- Optional caching (disabled by default for open source users) +- Uses Scryfall bulk data API (respects rate limits and guidelines) +- Downloads from Scryfall CDN (no rate limits on image files) +- Progress tracking for long downloads +- Resume capability if interrupted +- Graceful fallback to API if images missing + +Environment Variables: + CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0) + +Image Sizes: + - small: 160px width (for list views) + - normal: 488px width (for prominent displays, hover previews) + +Directory Structure: + card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB) + card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB) + +See: https://scryfall.com/docs/api +""" + +import json +import logging +import os +import re +import time +from pathlib import Path +from typing import Any, Optional +from urllib.request import Request, urlopen + +from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient + +logger = logging.getLogger(__name__) + +# Scryfall CDN has no rate limits, but we'll be conservative +DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec) + +# Image sizes to cache +IMAGE_SIZES = ["small", "normal"] + +# Card name sanitization (filesystem-safe) +INVALID_CHARS = r'[<>:"/\\|?*]' + + +def sanitize_filename(card_name: str) -> str: + """ + Sanitize card name for use as filename. + + Args: + card_name: Original card name + + Returns: + Filesystem-safe filename + """ + # Replace invalid characters with underscore + safe_name = re.sub(INVALID_CHARS, "_", card_name) + # Remove multiple consecutive underscores + safe_name = re.sub(r"_+", "_", safe_name) + # Trim leading/trailing underscores + safe_name = safe_name.strip("_") + return safe_name + + +class ImageCache: + """Manages local card image cache.""" + + def __init__( + self, + base_dir: str = "card_files/images", + bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json", + ): + """ + Initialize image cache. + + Args: + base_dir: Base directory for cached images + bulk_data_path: Path to Scryfall bulk data JSON + """ + self.base_dir = Path(base_dir) + self.bulk_data_path = Path(bulk_data_path) + self.client = ScryfallBulkDataClient() + self._last_download_time: float = 0.0 + + def is_enabled(self) -> bool: + """Check if image caching is enabled via environment variable.""" + return os.getenv("CACHE_CARD_IMAGES", "0") == "1" + + def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]: + """ + Get local path to cached image if it exists. + + Args: + card_name: Card name + size: Image size ('small' or 'normal') + + Returns: + Path to cached image, or None if not cached + """ + if not self.is_enabled(): + return None + + safe_name = sanitize_filename(card_name) + image_path = self.base_dir / size / f"{safe_name}.jpg" + + if image_path.exists(): + return image_path + return None + + def get_image_url(self, card_name: str, size: str = "normal") -> str: + """ + Get image URL (local path if cached, Scryfall API otherwise). + + Args: + card_name: Card name + size: Image size ('small' or 'normal') + + Returns: + URL or local path to image + """ + # Check local cache first + local_path = self.get_image_path(card_name, size) + if local_path: + # Return as static file path for web serving + return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg" + + # Fallback to Scryfall API + from urllib.parse import quote + card_query = quote(card_name) + return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}" + + def _rate_limit_wait(self) -> None: + """Wait to respect rate limits between downloads.""" + elapsed = time.time() - self._last_download_time + if elapsed < DOWNLOAD_DELAY: + time.sleep(DOWNLOAD_DELAY - elapsed) + self._last_download_time = time.time() + + def _download_image(self, image_url: str, output_path: Path) -> bool: + """ + Download single image from Scryfall CDN. + + Args: + image_url: Image URL from bulk data + output_path: Local path to save image + + Returns: + True if successful, False otherwise + """ + self._rate_limit_wait() + + try: + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + req = Request(image_url) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + + with urlopen(req, timeout=30) as response: + image_data = response.read() + with open(output_path, "wb") as f: + f.write(image_data) + + return True + + except Exception as e: + logger.debug(f"Failed to download {image_url}: {e}") + # Clean up partial download + if output_path.exists(): + output_path.unlink() + return False + + def _load_bulk_data(self) -> list[dict[str, Any]]: + """ + Load card data from bulk data JSON. + + Returns: + List of card objects with image URLs + + Raises: + FileNotFoundError: If bulk data file doesn't exist + json.JSONDecodeError: If file is invalid JSON + """ + if not self.bulk_data_path.exists(): + raise FileNotFoundError( + f"Bulk data file not found: {self.bulk_data_path}. " + "Run download_bulk_data() first." + ) + + logger.info(f"Loading bulk data from {self.bulk_data_path}") + with open(self.bulk_data_path, "r", encoding="utf-8") as f: + return json.load(f) + + def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Filter bulk data to only cards in our all_cards.parquet file. + Deduplicates by card name (takes first printing only). + + Args: + bulk_cards: Full Scryfall bulk data + + Returns: + Filtered list of cards matching our dataset (one per unique name) + """ + try: + import pandas as pd + from code.path_util import get_processed_cards_path + + # Load our card names + parquet_path = get_processed_cards_path() + df = pd.read_parquet(parquet_path, columns=["name"]) + our_card_names = set(df["name"].str.lower()) + + logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset") + + # Filter and deduplicate - keep only first printing of each card + seen_names = set() + filtered = [] + + for card in bulk_cards: + card_name_lower = card.get("name", "").lower() + if card_name_lower in our_card_names and card_name_lower not in seen_names: + filtered.append(card) + seen_names.add(card_name_lower) + + logger.info(f"Filtered to {len(filtered)} unique cards with image data") + return filtered + + except Exception as e: + logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.") + return bulk_cards + + def download_bulk_data(self, progress_callback=None) -> None: + """ + Download latest Scryfall bulk data JSON. + + Args: + progress_callback: Optional callback(bytes_downloaded, total_bytes) + + Raises: + Exception: If download fails + """ + logger.info("Downloading Scryfall bulk data...") + self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True) + self.client.get_bulk_data( + output_path=str(self.bulk_data_path), + progress_callback=progress_callback, + ) + logger.info("Bulk data download complete") + + def download_images( + self, + sizes: Optional[list[str]] = None, + progress_callback=None, + max_cards: Optional[int] = None, + ) -> dict[str, int]: + """ + Download card images from Scryfall CDN. + + Args: + sizes: Image sizes to download (default: ['small', 'normal']) + progress_callback: Optional callback(current, total, card_name) + max_cards: Maximum cards to download (for testing) + + Returns: + Dictionary with download statistics + + Raises: + FileNotFoundError: If bulk data not available + """ + if not self.is_enabled(): + logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)") + return {"skipped": 0} + + if sizes is None: + sizes = IMAGE_SIZES + + logger.info(f"Starting image download for sizes: {sizes}") + + # Load bulk data and filter to our cards + bulk_cards = self._load_bulk_data() + cards = self._filter_to_our_cards(bulk_cards) + total_cards = len(cards) if max_cards is None else min(max_cards, len(cards)) + + stats = { + "total": total_cards, + "downloaded": 0, + "skipped": 0, + "failed": 0, + } + + for i, card in enumerate(cards[:total_cards]): + card_name = card.get("name") + if not card_name: + stats["skipped"] += 1 + continue + + # Collect all faces to download (single-faced or multi-faced) + faces_to_download = [] + + # Check if card has direct image_uris (single-faced card) + if card.get("image_uris"): + faces_to_download.append({ + "name": card_name, + "image_uris": card["image_uris"], + }) + # Handle double-faced cards (get all faces) + elif card.get("card_faces"): + for face_idx, face in enumerate(card["card_faces"]): + if face.get("image_uris"): + # For multi-faced cards, append face name or index + face_name = face.get("name", f"{card_name}_face{face_idx}") + faces_to_download.append({ + "name": face_name, + "image_uris": face["image_uris"], + }) + + # Skip if no faces found + if not faces_to_download: + logger.debug(f"No image URIs for {card_name}") + stats["skipped"] += 1 + continue + + # Download each face in each requested size + for face in faces_to_download: + face_name = face["name"] + image_uris = face["image_uris"] + + for size in sizes: + image_url = image_uris.get(size) + if not image_url: + continue + + # Check if already cached + safe_name = sanitize_filename(face_name) + output_path = self.base_dir / size / f"{safe_name}.jpg" + + if output_path.exists(): + stats["skipped"] += 1 + continue + + # Download image + if self._download_image(image_url, output_path): + stats["downloaded"] += 1 + else: + stats["failed"] += 1 + + # Progress callback + if progress_callback: + progress_callback(i + 1, total_cards, card_name) + + # Invalidate cached summary since we just downloaded new images + self.invalidate_summary_cache() + + logger.info(f"Image download complete: {stats}") + return stats + + def cache_statistics(self) -> dict[str, Any]: + """ + Get statistics about cached images. + + Uses a cached summary.json file to avoid scanning thousands of files. + Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS, + default 7 days, matching the main card data staleness check). + + Returns: + Dictionary with cache stats (count, size, etc.) + """ + stats = {"enabled": self.is_enabled()} + + if not self.is_enabled(): + return stats + + summary_file = self.base_dir / "summary.json" + + # Get staleness threshold from environment (same as card data check) + try: + refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7')) + except Exception: + refresh_days = 7 + + if refresh_days <= 0: + # Never consider stale + refresh_seconds = float('inf') + else: + refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds + + # Check if summary exists and is recent (less than refresh_seconds old) + use_cached = False + if summary_file.exists(): + try: + import time + file_age = time.time() - summary_file.stat().st_mtime + if file_age < refresh_seconds: + use_cached = True + except Exception: + pass + + # Try to use cached summary + if use_cached: + try: + import json + with summary_file.open('r', encoding='utf-8') as f: + cached_stats = json.load(f) + stats.update(cached_stats) + return stats + except Exception as e: + logger.warning(f"Could not read cache summary: {e}") + + # Regenerate summary (fast - just count files and estimate size) + for size in IMAGE_SIZES: + size_dir = self.base_dir / size + if size_dir.exists(): + # Fast count: count .jpg files without statting each one + count = sum(1 for _ in size_dir.glob("*.jpg")) + + # Estimate total size based on typical averages to avoid stat() calls + # Small images: ~40 KB avg, Normal images: ~100 KB avg + avg_size_kb = 40 if size == "small" else 100 + estimated_size_mb = (count * avg_size_kb) / 1024 + + stats[size] = { + "count": count, + "size_mb": round(estimated_size_mb, 1), + } + else: + stats[size] = {"count": 0, "size_mb": 0.0} + + # Save summary for next time + try: + import json + with summary_file.open('w', encoding='utf-8') as f: + json.dump({k: v for k, v in stats.items() if k != "enabled"}, f) + except Exception as e: + logger.warning(f"Could not write cache summary: {e}") + + return stats + + def invalidate_summary_cache(self) -> None: + """Delete the cached summary file to force regeneration on next call.""" + if not self.is_enabled(): + return + + summary_file = self.base_dir / "summary.json" + if summary_file.exists(): + try: + summary_file.unlink() + logger.debug("Invalidated cache summary file") + except Exception as e: + logger.warning(f"Could not delete cache summary: {e}") + + +def main(): + """CLI entry point for image caching.""" + import argparse + + parser = argparse.ArgumentParser(description="Card image cache management") + parser.add_argument( + "--download", + action="store_true", + help="Download images from Scryfall", + ) + parser.add_argument( + "--stats", + action="store_true", + help="Show cache statistics", + ) + parser.add_argument( + "--max-cards", + type=int, + help="Maximum cards to download (for testing)", + ) + parser.add_argument( + "--sizes", + nargs="+", + default=IMAGE_SIZES, + choices=IMAGE_SIZES, + help="Image sizes to download", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force re-download of bulk data even if recent", + ) + + args = parser.parse_args() + + # Setup logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + cache = ImageCache() + + if args.stats: + stats = cache.cache_statistics() + print("\nCache Statistics:") + print(f" Enabled: {stats['enabled']}") + if stats["enabled"]: + for size in IMAGE_SIZES: + if size in stats: + print( + f" {size.capitalize()}: {stats[size]['count']} images " + f"({stats[size]['size_mb']:.1f} MB)" + ) + + elif args.download: + if not cache.is_enabled(): + print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.") + return + + # Check if bulk data already exists and is recent (within 24 hours) + bulk_data_exists = cache.bulk_data_path.exists() + bulk_data_age_hours = None + + if bulk_data_exists: + import time + age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime + bulk_data_age_hours = age_seconds / 3600 + print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)") + + # Download bulk data if missing, old, or forced + if not bulk_data_exists or bulk_data_age_hours > 24 or args.force: + print("Downloading Scryfall bulk data...") + + def bulk_progress(downloaded, total): + if total > 0: + pct = (downloaded / total) * 100 + print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / " + f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r") + + cache.download_bulk_data(progress_callback=bulk_progress) + print("\nBulk data downloaded successfully") + else: + print("Bulk data is recent, skipping download (use --force to re-download)") + + # Download images + print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...") + + def image_progress(current, total, card_name): + pct = (current / total) * 100 + print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r") + + stats = cache.download_images( + sizes=args.sizes, + progress_callback=image_progress, + max_cards=args.max_cards, + ) + print("\n\nDownload complete:") + print(f" Total: {stats['total']}") + print(f" Downloaded: {stats['downloaded']}") + print(f" Skipped: {stats['skipped']}") + print(f" Failed: {stats['failed']}") + + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/code/file_setup/old/setup.py b/code/file_setup/old/setup.py index b377017..104aa06 100644 --- a/code/file_setup/old/setup.py +++ b/code/file_setup/old/setup.py @@ -40,7 +40,7 @@ from typing import List, Dict, Any # Third-party imports (optional) try: - import inquirer # type: ignore + import inquirer except Exception: inquirer = None # Fallback to simple input-based menu when unavailable import pandas as pd diff --git a/code/file_setup/old/setup_csv.py b/code/file_setup/old/setup_csv.py index c48dc9d..247597f 100644 --- a/code/file_setup/old/setup_csv.py +++ b/code/file_setup/old/setup_csv.py @@ -40,7 +40,7 @@ from typing import List, Dict, Any # Third-party imports (optional) try: - import inquirer # type: ignore + import inquirer except Exception: inquirer = None # Fallback to simple input-based menu when unavailable import pandas as pd diff --git a/code/file_setup/scryfall_bulk_data.py b/code/file_setup/scryfall_bulk_data.py new file mode 100644 index 0000000..fd41d90 --- /dev/null +++ b/code/file_setup/scryfall_bulk_data.py @@ -0,0 +1,169 @@ +""" +Scryfall Bulk Data API client. + +Fetches bulk data JSON files from Scryfall's bulk data API, which provides +all card information including image URLs without hitting rate limits. + +See: https://scryfall.com/docs/api/bulk-data +""" + +import logging +import os +import time +from typing import Any +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data" +DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database +RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines) + + +class ScryfallBulkDataClient: + """Client for fetching Scryfall bulk data.""" + + def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY): + """ + Initialize Scryfall bulk data client. + + Args: + rate_limit_delay: Seconds to wait between API requests (default 100ms) + """ + self.rate_limit_delay = rate_limit_delay + self._last_request_time: float = 0.0 + + def _rate_limit_wait(self) -> None: + """Wait to respect rate limits between API calls.""" + elapsed = time.time() - self._last_request_time + if elapsed < self.rate_limit_delay: + time.sleep(self.rate_limit_delay - elapsed) + self._last_request_time = time.time() + + def _make_request(self, url: str) -> Any: + """ + Make HTTP request with rate limiting and error handling. + + Args: + url: URL to fetch + + Returns: + Parsed JSON response + + Raises: + Exception: If request fails after retries + """ + self._rate_limit_wait() + + try: + req = Request(url) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + with urlopen(req, timeout=30) as response: + import json + return json.loads(response.read().decode("utf-8")) + except Exception as e: + logger.error(f"Failed to fetch {url}: {e}") + raise + + def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]: + """ + Get bulk data metadata (download URL, size, last updated). + + Args: + bulk_type: Type of bulk data to fetch (default: default_cards) + + Returns: + Dictionary with bulk data info including 'download_uri' + + Raises: + ValueError: If bulk_type not found + Exception: If API request fails + """ + logger.info(f"Fetching bulk data info for type: {bulk_type}") + response = self._make_request(BULK_DATA_API_URL) + + # Find the requested bulk data type + for item in response.get("data", []): + if item.get("type") == bulk_type: + logger.info( + f"Found bulk data: {item.get('name')} " + f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, " + f"updated: {item.get('updated_at', 'unknown')})" + ) + return item + + raise ValueError(f"Bulk data type '{bulk_type}' not found") + + def download_bulk_data( + self, download_uri: str, output_path: str, progress_callback=None + ) -> None: + """ + Download bulk data JSON file. + + Args: + download_uri: Direct download URL from get_bulk_data_info() + output_path: Local path to save the JSON file + progress_callback: Optional callback(bytes_downloaded, total_bytes) + + Raises: + Exception: If download fails + """ + logger.info(f"Downloading bulk data from: {download_uri}") + logger.info(f"Saving to: {output_path}") + + # No rate limit on bulk data downloads per Scryfall docs + try: + req = Request(download_uri) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + + with urlopen(req, timeout=60) as response: + total_size = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + chunk_size = 1024 * 1024 # 1MB chunks + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if progress_callback: + progress_callback(downloaded, total_size) + + logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully") + + except Exception as e: + logger.error(f"Failed to download bulk data: {e}") + # Clean up partial download + if os.path.exists(output_path): + os.remove(output_path) + raise + + def get_bulk_data( + self, + bulk_type: str = DEFAULT_BULK_TYPE, + output_path: str = "card_files/raw/scryfall_bulk_data.json", + progress_callback=None, + ) -> str: + """ + Fetch bulk data info and download the JSON file. + + Args: + bulk_type: Type of bulk data to fetch + output_path: Where to save the JSON file + progress_callback: Optional progress callback + + Returns: + Path to downloaded file + + Raises: + Exception: If fetch or download fails + """ + info = self.get_bulk_data_info(bulk_type) + download_uri = info["download_uri"] + self.download_bulk_data(download_uri, output_path, progress_callback) + return output_path diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index 0b01e21..62a8165 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -349,6 +349,44 @@ def initial_setup() -> None: logger.info(f" Raw: {raw_path}") logger.info(f" Processed: {processed_path}") logger.info("=" * 80) + + # Step 3: Optional image caching (if enabled) + try: + from code.file_setup.image_cache import ImageCache + cache = ImageCache() + + if cache.is_enabled(): + logger.info("=" * 80) + logger.info("Card image caching enabled - starting download") + logger.info("=" * 80) + + # Download bulk data + logger.info("Downloading Scryfall bulk data...") + cache.download_bulk_data() + + # Download images + logger.info("Downloading card images (this may take 1-2 hours)...") + + def progress(current, total, card_name): + if current % 100 == 0: # Log every 100 cards + pct = (current / total) * 100 + logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}") + + stats = cache.download_images(progress_callback=progress) + + logger.info("=" * 80) + logger.info("✓ Image cache complete") + logger.info(f" Downloaded: {stats['downloaded']}") + logger.info(f" Skipped: {stats['skipped']}") + logger.info(f" Failed: {stats['failed']}") + logger.info("=" * 80) + else: + logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)") + logger.info("Images will be fetched from Scryfall API on demand") + + except Exception as e: + logger.error(f"Failed to cache images (continuing anyway): {e}") + logger.error("Images will be fetched from Scryfall API on demand") def regenerate_processed_parquet() -> None: diff --git a/code/headless_runner.py b/code/headless_runner.py index 0292ccd..ff3bfbc 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -139,7 +139,7 @@ def _validate_commander_available(command_name: str) -> None: return try: - from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found] + from commander_exclusions import lookup_commander_detail as _lookup_commander_detail except ImportError: # pragma: no cover _lookup_commander_detail = None @@ -281,12 +281,12 @@ def run( # Optional deterministic seed for Random Modes (does not affect core when unset) try: if seed is not None: - builder.set_seed(seed) # type: ignore[attr-defined] + builder.set_seed(seed) except Exception: pass # Mark this run as headless so builder can adjust exports and logging try: - builder.headless = True # type: ignore[attr-defined] + builder.headless = True except Exception: pass @@ -294,9 +294,9 @@ def run( secondary_clean = (secondary_commander or "").strip() background_clean = (background or "").strip() try: - builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined] - builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined] - builder.requested_background = background_clean or None # type: ignore[attr-defined] + builder.partner_feature_enabled = partner_feature_enabled + builder.requested_secondary_commander = secondary_clean or None + builder.requested_background = background_clean or None except Exception: pass @@ -313,11 +313,11 @@ def run( # Configure include/exclude settings (M1: Config + Validation + Persistence) try: - builder.include_cards = list(include_cards or []) # type: ignore[attr-defined] - builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined] - builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined] - builder.allow_illegal = allow_illegal # type: ignore[attr-defined] - builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined] + builder.include_cards = list(include_cards or []) + builder.exclude_cards = list(exclude_cards or []) + builder.enforcement_mode = enforcement_mode + builder.allow_illegal = allow_illegal + builder.fuzzy_matching = fuzzy_matching except Exception: pass @@ -336,16 +336,16 @@ def run( ) try: - builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined] - builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined] - builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined] - builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined] - builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined] - builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined] - builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined] - builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined] + builder.theme_match_mode = theme_resolution.mode + builder.theme_catalog_version = theme_resolution.catalog_version + builder.user_theme_requested = list(theme_resolution.requested) + builder.user_theme_resolved = list(theme_resolution.resolved) + builder.user_theme_matches = list(theme_resolution.matches) + builder.user_theme_unresolved = list(theme_resolution.unresolved) + builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) + builder.user_theme_resolution = theme_resolution if user_theme_weight is not None: - builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined] + builder.user_theme_weight = float(user_theme_weight) except Exception: pass @@ -356,7 +356,7 @@ def run( ic: Dict[str, int] = {} for k, v in ideal_counts.items(): try: - iv = int(v) if v is not None else None # type: ignore + iv = int(v) if v is not None else None except Exception: continue if iv is None: @@ -365,7 +365,7 @@ def run( if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}: ic[k] = iv if ic: - builder.ideal_counts.update(ic) # type: ignore[attr-defined] + builder.ideal_counts.update(ic) except Exception: pass builder.run_initial_setup() @@ -518,24 +518,24 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commande """Attach combined commander metadata to the builder for downstream use.""" try: - builder.combined_commander = combined_commander # type: ignore[attr-defined] + builder.combined_commander = combined_commander except Exception: pass try: - builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined] + builder.partner_mode = combined_commander.partner_mode except Exception: pass try: - builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined] + builder.secondary_commander = combined_commander.secondary_name except Exception: pass try: - builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined] - builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined] - builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined] + builder.combined_color_identity = combined_commander.color_identity + builder.combined_theme_tags = combined_commander.theme_tags + builder.partner_warnings = combined_commander.warnings except Exception: pass @@ -557,7 +557,7 @@ def _export_outputs(builder: DeckBuilder) -> None: # Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export if csv_path: try: - builder.last_csv_path = csv_path # type: ignore[attr-defined] + builder.last_csv_path = csv_path except Exception: pass except Exception: @@ -572,7 +572,7 @@ def _export_outputs(builder: DeckBuilder) -> None: finally: if txt_generated: try: - builder.last_txt_path = txt_generated # type: ignore[attr-defined] + builder.last_txt_path = txt_generated except Exception: pass else: @@ -582,7 +582,7 @@ def _export_outputs(builder: DeckBuilder) -> None: finally: if txt_generated: try: - builder.last_txt_path = txt_generated # type: ignore[attr-defined] + builder.last_txt_path = txt_generated except Exception: pass except Exception: @@ -1196,7 +1196,7 @@ def _run_random_mode(config: RandomRunConfig) -> int: RandomConstraintsImpossibleError, RandomThemeNoMatchError, build_random_full_deck, - ) # type: ignore + ) except Exception as exc: print(f"Random mode unavailable: {exc}") return 1 diff --git a/code/scripts/build_theme_catalog.py b/code/scripts/build_theme_catalog.py index 43c70ca..4f2f722 100644 --- a/code/scripts/build_theme_catalog.py +++ b/code/scripts/build_theme_catalog.py @@ -36,7 +36,7 @@ except Exception: # pragma: no cover try: # Support running as `python code/scripts/build_theme_catalog.py` when 'code' already on path - from scripts.extract_themes import ( # type: ignore + from scripts.extract_themes import ( BASE_COLORS, collect_theme_tags_from_constants, collect_theme_tags_from_tagger_source, @@ -51,7 +51,7 @@ try: ) except ModuleNotFoundError: # Fallback: direct relative import when running within scripts package context - from extract_themes import ( # type: ignore + from extract_themes import ( BASE_COLORS, collect_theme_tags_from_constants, collect_theme_tags_from_tagger_source, @@ -66,7 +66,7 @@ except ModuleNotFoundError: ) try: - from scripts.export_themes_to_yaml import slugify as slugify_theme # type: ignore + from scripts.export_themes_to_yaml import slugify as slugify_theme except Exception: _SLUG_RE = re.compile(r'[^a-z0-9-]') @@ -951,7 +951,7 @@ def main(): # pragma: no cover if args.schema: # Lazy import to avoid circular dependency: replicate minimal schema inline from models file if present try: - from type_definitions_theme_catalog import ThemeCatalog # type: ignore + from type_definitions_theme_catalog import ThemeCatalog import json as _json print(_json.dumps(ThemeCatalog.model_json_schema(), indent=2)) return @@ -990,8 +990,8 @@ def main(): # pragma: no cover # Safeguard: if catalog dir missing, attempt to auto-export Phase A YAML first if not CATALOG_DIR.exists(): # pragma: no cover (environmental) try: - from scripts.export_themes_to_yaml import main as export_main # type: ignore - export_main(['--force']) # type: ignore[arg-type] + from scripts.export_themes_to_yaml import main as export_main + export_main(['--force']) except Exception as _e: print(f"[build_theme_catalog] WARNING: catalog dir missing and auto export failed: {_e}", file=sys.stderr) if yaml is None: @@ -1013,7 +1013,7 @@ def main(): # pragma: no cover meta_block = raw.get('metadata_info') if isinstance(raw.get('metadata_info'), dict) else {} # Legacy migration: if no metadata_info but legacy provenance present, adopt it if not meta_block and isinstance(raw.get('provenance'), dict): - meta_block = raw.get('provenance') # type: ignore + meta_block = raw.get('provenance') changed = True if force or not meta_block.get('last_backfill'): meta_block['last_backfill'] = time.strftime('%Y-%m-%dT%H:%M:%S') diff --git a/code/scripts/export_themes_to_yaml.py b/code/scripts/export_themes_to_yaml.py index a417e53..6f1d904 100644 --- a/code/scripts/export_themes_to_yaml.py +++ b/code/scripts/export_themes_to_yaml.py @@ -41,7 +41,7 @@ SCRIPT_ROOT = Path(__file__).resolve().parent CODE_ROOT = SCRIPT_ROOT.parent if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) -from scripts.extract_themes import derive_synergies_for_tags # type: ignore +from scripts.extract_themes import derive_synergies_for_tags ROOT = Path(__file__).resolve().parents[2] THEME_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' diff --git a/code/scripts/extract_themes.py b/code/scripts/extract_themes.py index c45e7c5..c4c1216 100644 --- a/code/scripts/extract_themes.py +++ b/code/scripts/extract_themes.py @@ -18,8 +18,8 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) if ROOT not in sys.path: sys.path.insert(0, ROOT) -from code.settings import CSV_DIRECTORY # type: ignore -from code.tagging import tag_constants # type: ignore +from code.settings import CSV_DIRECTORY +from code.tagging import tag_constants BASE_COLORS = { 'white': 'W', diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py index 39f197b..0ee68d4 100644 --- a/code/scripts/generate_theme_catalog.py +++ b/code/scripts/generate_theme_catalog.py @@ -32,7 +32,7 @@ if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) try: - from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore + from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY except Exception: # pragma: no cover - fallback for adhoc execution DEFAULT_CSV_DIRECTORY = "csv_files" diff --git a/code/scripts/profile_multi_theme_filter.py b/code/scripts/profile_multi_theme_filter.py index 2af36c0..795bc62 100644 --- a/code/scripts/profile_multi_theme_filter.py +++ b/code/scripts/profile_multi_theme_filter.py @@ -42,7 +42,7 @@ def _sample_combinations(tags: List[str], iterations: int) -> List[Tuple[str | N def _collect_tag_pool(df: pd.DataFrame) -> List[str]: tag_pool: set[str] = set() - for tags in df.get("_ltags", []): # type: ignore[assignment] + for tags in df.get("_ltags", []): if not tags: continue for token in tags: diff --git a/code/scripts/refresh_commander_catalog.py b/code/scripts/refresh_commander_catalog.py index c9f107e..19b4634 100644 --- a/code/scripts/refresh_commander_catalog.py +++ b/code/scripts/refresh_commander_catalog.py @@ -37,7 +37,7 @@ def _refresh_setup() -> None: def _refresh_tags() -> None: tagger = importlib.import_module("code.tagging.tagger") - tagger = importlib.reload(tagger) # type: ignore[assignment] + tagger = importlib.reload(tagger) for color in SUPPORTED_COLORS: tagger.load_dataframe(color) diff --git a/code/scripts/report_random_theme_pool.py b/code/scripts/report_random_theme_pool.py index 1b3833f..09140ae 100644 --- a/code/scripts/report_random_theme_pool.py +++ b/code/scripts/report_random_theme_pool.py @@ -21,7 +21,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.append(str(PROJECT_ROOT)) -from deck_builder.random_entrypoint import ( # type: ignore # noqa: E402 +from deck_builder.random_entrypoint import ( # noqa: E402 _build_random_theme_pool, _ensure_theme_tag_cache, _load_commanders_df, diff --git a/code/scripts/synergy_promote_fill.py b/code/scripts/synergy_promote_fill.py index 3c49af0..ca878f2 100644 --- a/code/scripts/synergy_promote_fill.py +++ b/code/scripts/synergy_promote_fill.py @@ -731,7 +731,7 @@ def main(): # pragma: no cover (script orchestration) if cand: theme_card_hits[display] = cand # Build global duplicate frequency map ONCE (baseline prior to this run) if threshold active - if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): # type: ignore + if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): freq: Dict[str, int] = {} total_themes = 0 for fp0 in CATALOG_DIR.glob('*.yml'): @@ -748,10 +748,10 @@ def main(): # pragma: no cover (script orchestration) continue seen_local.add(c) freq[c] = freq.get(c, 0) + 1 - globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # type: ignore + globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards) - if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore + if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): + freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] if total_prev > 0: # avoid div-by-zero cutoff = args.common_card_threshold def _filter(lst: List[Tuple[float, str, Set[str]]]) -> List[Tuple[float, str, Set[str]]]: @@ -803,8 +803,8 @@ def main(): # pragma: no cover (script orchestration) print(f"[promote] modified {changed_count} themes") if args.fill_example_cards: print(f"[cards] modified {cards_changed} themes (target {args.cards_target})") - if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore + if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): + freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] if total_prev: items = sorted(freq_map.items(), key=lambda x: (-x[1], x[0]))[:30] print('[dup-metrics] Top shared example_cards (baseline before this run):') diff --git a/code/scripts/validate_theme_catalog.py b/code/scripts/validate_theme_catalog.py index 1b18962..c6b3627 100644 --- a/code/scripts/validate_theme_catalog.py +++ b/code/scripts/validate_theme_catalog.py @@ -31,9 +31,9 @@ CODE_ROOT = ROOT / 'code' if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) -from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile # type: ignore -from scripts.extract_themes import load_whitelist_config # type: ignore -from scripts.build_theme_catalog import build_catalog # type: ignore +from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile +from scripts.extract_themes import load_whitelist_config +from scripts.build_theme_catalog import build_catalog CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' diff --git a/code/settings.py b/code/settings.py index 242e58a..fb1caa9 100644 --- a/code/settings.py +++ b/code/settings.py @@ -89,11 +89,8 @@ COLUMN_ORDER = CARD_COLUMN_ORDER TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS -MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit'] +# MAIN_MENU_ITEMS, SETUP_MENU_ITEMS, CSV_DIRECTORY already defined above (lines 67-70) -SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu'] - -CSV_DIRECTORY: str = 'csv_files' CARD_FILES_DIRECTORY: str = 'card_files' # Parquet files for consolidated card data # ---------------------------------------------------------------------------------- @@ -111,11 +108,7 @@ CARD_FILES_PROCESSED_DIR = os.getenv('CARD_FILES_PROCESSED_DIR', os.path.join(CA # Set to '1' or 'true' to enable CSV fallback when Parquet loading fails LEGACY_CSV_COMPAT = os.getenv('LEGACY_CSV_COMPAT', '0').lower() in ('1', 'true', 'on', 'enabled') -# Configuration for handling null/NA values in DataFrame columns -FILL_NA_COLUMNS: Dict[str, Optional[str]] = { - 'colorIdentity': 'Colorless', # Default color identity for cards without one - 'faceName': None # Use card's name column value when face name is not available -} +# FILL_NA_COLUMNS already defined above (lines 75-78) # ---------------------------------------------------------------------------------- # ALL CARDS CONSOLIDATION FEATURE FLAG diff --git a/code/tagging/bracket_policy_applier.py b/code/tagging/bracket_policy_applier.py index 80c63b0..5265dd7 100644 --- a/code/tagging/bracket_policy_applier.py +++ b/code/tagging/bracket_policy_applier.py @@ -30,14 +30,14 @@ try: import logging_util except Exception: # Fallback for direct module loading - import importlib.util # type: ignore + import importlib.util root = Path(__file__).resolve().parents[1] lu_path = root / 'logging_util.py' spec = importlib.util.spec_from_file_location('logging_util', str(lu_path)) mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] assert spec and spec.loader - spec.loader.exec_module(mod) # type: ignore[assignment] - logging_util = mod # type: ignore + spec.loader.exec_module(mod) + logging_util = mod logger = logging_util.logging.getLogger(__name__) logger.setLevel(logging_util.LOG_LEVEL) diff --git a/code/tagging/multi_face_merger.py b/code/tagging/multi_face_merger.py index 0dd2753..deb31ac 100644 --- a/code/tagging/multi_face_merger.py +++ b/code/tagging/multi_face_merger.py @@ -240,6 +240,13 @@ def merge_multi_face_rows( faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()] + # M9: Capture back face type for MDFC land detection + if len(group_sorted) >= 2 and "type" in group_sorted.columns: + back_face_row = group_sorted.iloc[1] + back_type = str(back_face_row.get("type", "") or "") + if back_type: + work_df.at[primary_idx, "backType"] = back_type + drop_indices.extend(group_sorted.index[1:]) merged_count += 1 diff --git a/code/tagging/old/tagger.py b/code/tagging/old/tagger.py index b805102..db31b43 100644 --- a/code/tagging/old/tagger.py +++ b/code/tagging/old/tagger.py @@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str): def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: - try: # type: ignore[name-defined] + try: _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" df.to_csv(path, index=False) diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index cc08214..3251bf6 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -173,7 +173,7 @@ def _merge_summary_recorder(color: str): def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: """Write DFC compatibility snapshot (diagnostic output, kept as CSV for now).""" - try: # type: ignore[name-defined] + try: _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" df.to_csv(path, index=False) # M3: Kept as CSV (diagnostic only, not main data flow) diff --git a/code/tests/test_bracket_policy_applier.py b/code/tests/test_bracket_policy_applier.py index d7d5dfe..17ad9c8 100644 --- a/code/tests/test_bracket_policy_applier.py +++ b/code/tests/test_bracket_policy_applier.py @@ -11,9 +11,9 @@ def _load_applier(): root = Path(__file__).resolve().parents[2] mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py' spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path)) - mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + mod = importlib.util.module_from_spec(spec) assert spec and spec.loader - spec.loader.exec_module(mod) # type: ignore[assignment] + spec.loader.exec_module(mod) return mod diff --git a/code/tests/test_card_index_color_identity_edge_cases.py b/code/tests/test_card_index_color_identity_edge_cases.py index 8a734ed..0969bf3 100644 --- a/code/tests/test_card_index_color_identity_edge_cases.py +++ b/code/tests/test_card_index_color_identity_edge_cases.py @@ -30,8 +30,8 @@ def test_card_index_color_identity_list_handles_edge_cases(tmp_path, monkeypatch csv_path = write_csv(tmp_path) monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path)) # Force rebuild - card_index._CARD_INDEX.clear() # type: ignore - card_index._CARD_INDEX_MTIME = None # type: ignore + card_index._CARD_INDEX.clear() + card_index._CARD_INDEX_MTIME = None card_index.maybe_build_index() pool = card_index.get_tag_pool("Blink") diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py index d61387a..337edf7 100644 --- a/code/tests/test_commander_build_cta.py +++ b/code/tests/test_commander_build_cta.py @@ -8,7 +8,7 @@ from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app from code.web.services.commander_catalog_loader import clear_commander_catalog_cache diff --git a/code/tests/test_commander_telemetry.py b/code/tests/test_commander_telemetry.py index d566da4..d978252 100644 --- a/code/tests/test_commander_telemetry.py +++ b/code/tests/test_commander_telemetry.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app from code.web.services import telemetry from code.web.services.commander_catalog_loader import clear_commander_catalog_cache diff --git a/code/tests/test_commanders_route.py b/code/tests/test_commanders_route.py index 6f4d064..bf724f7 100644 --- a/code/tests/test_commanders_route.py +++ b/code/tests/test_commanders_route.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import pytest from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app from code.web.routes import commanders from code.web.services import commander_catalog_loader from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py index 4d38a2b..7ac06c5 100644 --- a/code/tests/test_diagnostics.py +++ b/code/tests/test_diagnostics.py @@ -24,7 +24,7 @@ def load_app_with_env(**env: str) -> types.ModuleType: os.environ.pop(key, None) for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module # type: ignore + import code.web.app as app_module importlib.reload(app_module) return app_module diff --git a/code/tests/test_editorial_governance_phase_d_closeout.py b/code/tests/test_editorial_governance_phase_d_closeout.py index e3713e0..83b1494 100644 --- a/code/tests/test_editorial_governance_phase_d_closeout.py +++ b/code/tests/test_editorial_governance_phase_d_closeout.py @@ -50,7 +50,7 @@ def _load_catalog() -> Dict[str, Any]: def test_deterministic_build_under_seed(): # Import build after setting seed env os.environ['EDITORIAL_SEED'] = '999' - from scripts.build_theme_catalog import build_catalog # type: ignore + from scripts.build_theme_catalog import build_catalog first = build_catalog(limit=0, verbose=False) second = build_catalog(limit=0, verbose=False) # Drop volatile metadata_info/timestamp fields before comparison @@ -106,7 +106,7 @@ def test_metadata_info_block_coverage(): def test_synergy_commanders_exclusion_of_examples(): - import yaml # type: ignore + import yaml pattern = re.compile(r" - Synergy \(.*\)$") violations: List[str] = [] for p in CATALOG_DIR.glob('*.yml'): @@ -128,7 +128,7 @@ def test_synergy_commanders_exclusion_of_examples(): def test_mapping_trigger_specialization_guard(): - import yaml # type: ignore + import yaml assert MAPPING.exists(), "description_mapping.yml missing" mapping_yaml = yaml.safe_load(MAPPING.read_text(encoding='utf-8')) or [] triggers: Set[str] = set() diff --git a/code/tests/test_home_actions_buttons.py b/code/tests/test_home_actions_buttons.py index 0dd2815..d9aaec3 100644 --- a/code/tests/test_home_actions_buttons.py +++ b/code/tests/test_home_actions_buttons.py @@ -20,7 +20,7 @@ def load_app_with_env(**env: str) -> types.ModuleType: os.environ.pop(key, None) for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module # type: ignore + import code.web.app as app_module importlib.reload(app_module) return app_module diff --git a/code/tests/test_land_summary_totals.py b/code/tests/test_land_summary_totals.py index 9fddcb2..b08ed16 100644 --- a/code/tests/test_land_summary_totals.py +++ b/code/tests/test_land_summary_totals.py @@ -14,7 +14,7 @@ class DummyBuilder(ReportingMixin): self.card_library = card_library self.color_identity = colors self.output_lines: List[str] = [] - self.output_func = self.output_lines.append # type: ignore[assignment] + self.output_func = self.output_lines.append self._full_cards_df = None self._combined_cards_df = None self.include_exclude_diagnostics = None diff --git a/code/tests/test_mdfc_basic_swap.py b/code/tests/test_mdfc_basic_swap.py index e78dafa..535f8da 100644 --- a/code/tests/test_mdfc_basic_swap.py +++ b/code/tests/test_mdfc_basic_swap.py @@ -20,7 +20,7 @@ def _stub_modal_matrix(builder: DeckBuilder) -> None: "Forest": {"G": 1}, } - builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined] + builder._compute_color_source_matrix = MethodType(fake_matrix, builder) def test_modal_dfc_swaps_basic_when_enabled(): diff --git a/code/tests/test_multicopy_clamp_strong.py b/code/tests/test_multicopy_clamp_strong.py index b7cdc4d..3538e6c 100644 --- a/code/tests/test_multicopy_clamp_strong.py +++ b/code/tests/test_multicopy_clamp_strong.py @@ -18,7 +18,7 @@ def test_multicopy_clamp_trims_current_stage_additions_only(): # Preseed 95 cards in the library b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}} # Set a multi-copy selection that would exceed 100 by 15 - b._web_multi_copy = { # type: ignore[attr-defined] + b._web_multi_copy = { "id": "persistent_petitioners", "name": "Persistent Petitioners", "count": 20, diff --git a/code/tests/test_multicopy_petitioners_clamp.py b/code/tests/test_multicopy_petitioners_clamp.py index e7a37c7..dfa8b7f 100644 --- a/code/tests/test_multicopy_petitioners_clamp.py +++ b/code/tests/test_multicopy_petitioners_clamp.py @@ -23,7 +23,7 @@ def test_petitioners_clamp_to_100_and_reduce_creature_slots(): "card_advantage": 8, "protection": 4, } # Thread multi-copy selection for Petitioners as a creature archetype - b._web_multi_copy = { # type: ignore[attr-defined] + b._web_multi_copy = { "id": "persistent_petitioners", "name": "Persistent Petitioners", "count": 40, # intentionally large to trigger clamp/adjustments diff --git a/code/tests/test_multicopy_stage_runner.py b/code/tests/test_multicopy_stage_runner.py index 886b277..4054fc0 100644 --- a/code/tests/test_multicopy_stage_runner.py +++ b/code/tests/test_multicopy_stage_runner.py @@ -17,7 +17,7 @@ def _minimal_ctx(selection: dict): b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True) # Thread selection and ensure empty library - b._web_multi_copy = selection # type: ignore[attr-defined] + b._web_multi_copy = selection b.card_library = {} ctx = { diff --git a/code/tests/test_multicopy_web_flow.py b/code/tests/test_multicopy_web_flow.py index 22fb79a..52f64c2 100644 --- a/code/tests/test_multicopy_web_flow.py +++ b/code/tests/test_multicopy_web_flow.py @@ -1,7 +1,7 @@ import importlib import pytest try: - from starlette.testclient import TestClient # type: ignore + from starlette.testclient import TestClient except Exception: # pragma: no cover - optional dep in CI TestClient = None # type: ignore diff --git a/code/tests/test_partner_suggestions_api.py b/code/tests/test_partner_suggestions_api.py index a54838f..5180329 100644 --- a/code/tests/test_partner_suggestions_api.py +++ b/code/tests/test_partner_suggestions_api.py @@ -128,7 +128,7 @@ def _make_request(path: str = "/api/partner/suggestions", query_string: str = "" "client": ("203.0.113.5", 52345), "server": ("testserver", 80), } - request = Request(scope, receive=_receive) # type: ignore[arg-type] + request = Request(scope, receive=_receive) request.state.request_id = "req-telemetry" return request @@ -197,21 +197,21 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey from code.web.services import orchestrator as orchestrator_service original_default = partner_service.DEFAULT_DATASET_PATH - original_path = partner_service._DATASET_PATH # type: ignore[attr-defined] - original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined] - original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined] + original_path = partner_service._DATASET_PATH + original_cache = partner_service._DATASET_CACHE + original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED partner_service.DEFAULT_DATASET_PATH = dataset_path - partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined] - partner_service._DATASET_CACHE = None # type: ignore[attr-defined] - partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined] + partner_service._DATASET_PATH = dataset_path + partner_service._DATASET_CACHE = None + partner_service._DATASET_REFRESH_ATTEMPTED = True calls = {"count": 0} payload_path = tmp_path / "seed_dataset.json" _write_dataset(payload_path) - def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override] + def seeded_refresh(out_func=None, *, force=False, root=None): calls["count"] += 1 dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8") @@ -227,9 +227,9 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey assert calls["count"] == 1 finally: partner_service.DEFAULT_DATASET_PATH = original_default - partner_service._DATASET_PATH = original_path # type: ignore[attr-defined] - partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined] - partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined] + partner_service._DATASET_PATH = original_path + partner_service._DATASET_CACHE = original_cache + partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted try: dataset_path.unlink() except FileNotFoundError: diff --git a/code/tests/test_partner_synergy_refresh.py b/code/tests/test_partner_synergy_refresh.py index cf3c2e1..984b79a 100644 --- a/code/tests/test_partner_synergy_refresh.py +++ b/code/tests/test_partner_synergy_refresh.py @@ -33,7 +33,7 @@ def _invoke_helper( ) -> list[tuple[list[str], str]]: calls: list[tuple[list[str], str]] = [] - def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def] + def _fake_run(cmd, check=False, cwd=None): calls.append((list(cmd), cwd)) class _Completed: returncode = 0 diff --git a/code/tests/test_preview_cache_redis_poc.py b/code/tests/test_preview_cache_redis_poc.py index 34e8c1e..afe616e 100644 --- a/code/tests/test_preview_cache_redis_poc.py +++ b/code/tests/test_preview_cache_redis_poc.py @@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") def load_app_with_env(**env: str) -> types.ModuleType: for k,v in env.items(): os.environ[k] = v - import code.web.app as app_module # type: ignore + import code.web.app as app_module importlib.reload(app_module) return app_module diff --git a/code/tests/test_preview_curated_examples_regression.py b/code/tests/test_preview_curated_examples_regression.py index 9839784..fc81d13 100644 --- a/code/tests/test_preview_curated_examples_regression.py +++ b/code/tests/test_preview_curated_examples_regression.py @@ -1,7 +1,7 @@ import json from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app def test_preview_includes_curated_examples_regression(): diff --git a/code/tests/test_preview_eviction_advanced.py b/code/tests/test_preview_eviction_advanced.py index 63447d5..337b6c2 100644 --- a/code/tests/test_preview_eviction_advanced.py +++ b/code/tests/test_preview_eviction_advanced.py @@ -1,8 +1,8 @@ import os -from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore -from code.web.services import preview_cache as pc # type: ignore -from code.web.services.preview_metrics import preview_metrics # type: ignore +from code.web.services.theme_preview import get_theme_preview, bust_preview_cache +from code.web.services import preview_cache as pc +from code.web.services.preview_metrics import preview_metrics def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None): @@ -89,7 +89,7 @@ def test_env_weight_override(monkeypatch): bust_preview_cache() # Clear module-level caches for weights if hasattr(pc, '_EVICT_WEIGHTS_CACHE'): - pc._EVICT_WEIGHTS_CACHE = None # type: ignore + pc._EVICT_WEIGHTS_CACHE = None # Create two entries: one older with many hits, one fresh with none. _prime('Blink', limit=6, hits=6, colors=None) # older hot entry old_key = next(iter(pc.PREVIEW_CACHE.keys())) diff --git a/code/tests/test_preview_eviction_basic.py b/code/tests/test_preview_eviction_basic.py index 848bcce..804c2d5 100644 --- a/code/tests/test_preview_eviction_basic.py +++ b/code/tests/test_preview_eviction_basic.py @@ -1,6 +1,6 @@ import os -from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore -from code.web.services import preview_cache as pc # type: ignore +from code.web.services.theme_preview import get_theme_preview, bust_preview_cache +from code.web.services import preview_cache as pc def test_basic_low_score_eviction(monkeypatch): @@ -17,7 +17,7 @@ def test_basic_low_score_eviction(monkeypatch): get_theme_preview('Blink', limit=6, colors=c) # Cache limit 5, inserted 6 distinct -> eviction should have occurred assert len(pc.PREVIEW_CACHE) <= 5 - from code.web.services.preview_metrics import preview_metrics # type: ignore + from code.web.services.preview_metrics import preview_metrics m = preview_metrics() assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction' assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1 diff --git a/code/tests/test_preview_minimal_variant.py b/code/tests/test_preview_minimal_variant.py index 2fec530..b134a23 100644 --- a/code/tests/test_preview_minimal_variant.py +++ b/code/tests/test_preview_minimal_variant.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app def test_minimal_variant_hides_controls_and_headers(): diff --git a/code/tests/test_preview_perf_fetch_retry.py b/code/tests/test_preview_perf_fetch_retry.py index 50b7ee5..a0bdb9a 100644 --- a/code/tests/test_preview_perf_fetch_retry.py +++ b/code/tests/test_preview_perf_fetch_retry.py @@ -8,7 +8,7 @@ pytestmark = pytest.mark.skip(reason="M4: preview_perf_benchmark module removed def test_fetch_all_theme_slugs_retries(monkeypatch): calls = {"count": 0} - def fake_fetch(url): # type: ignore[override] + def fake_fetch(url): calls["count"] += 1 if calls["count"] == 1: raise RuntimeError("transient 500") @@ -27,7 +27,7 @@ def test_fetch_all_theme_slugs_retries(monkeypatch): def test_fetch_all_theme_slugs_page_level_retry(monkeypatch): calls = {"count": 0} - def fake_fetch_with_retry(url, attempts=3, delay=0.6): # type: ignore[override] + def fake_fetch_with_retry(url, attempts=3, delay=0.6): calls["count"] += 1 if calls["count"] < 3: raise RuntimeError("service warming up") diff --git a/code/tests/test_preview_suppress_curated_flag.py b/code/tests/test_preview_suppress_curated_flag.py index 9ab5283..bea1467 100644 --- a/code/tests/test_preview_suppress_curated_flag.py +++ b/code/tests/test_preview_suppress_curated_flag.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app def test_preview_fragment_suppress_curated_removes_examples(): diff --git a/code/tests/test_preview_ttl_adaptive.py b/code/tests/test_preview_ttl_adaptive.py index e4b72b7..aa952d3 100644 --- a/code/tests/test_preview_ttl_adaptive.py +++ b/code/tests/test_preview_ttl_adaptive.py @@ -3,16 +3,16 @@ from code.web.services import preview_cache as pc def _force_interval_elapsed(): # Ensure adaptation interval guard passes - if pc._LAST_ADAPT_AT is not None: # type: ignore[attr-defined] - pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) # type: ignore[attr-defined] + if pc._LAST_ADAPT_AT is not None: + pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) def test_ttl_adapts_down_and_up(capsys): # Enable adaptation regardless of env - pc._ADAPTATION_ENABLED = True # type: ignore[attr-defined] - pc.TTL_SECONDS = pc._TTL_BASE # type: ignore[attr-defined] - pc._RECENT_HITS.clear() # type: ignore[attr-defined] - pc._LAST_ADAPT_AT = None # type: ignore[attr-defined] + pc._ADAPTATION_ENABLED = True + pc.TTL_SECONDS = pc._TTL_BASE + pc._RECENT_HITS.clear() + pc._LAST_ADAPT_AT = None # Low hit ratio pattern (~0.1) for _ in range(72): @@ -23,11 +23,11 @@ def test_ttl_adapts_down_and_up(capsys): out1 = capsys.readouterr().out assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio" ttl_after_down = pc.TTL_SECONDS - assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined] + assert ttl_after_down <= pc._TTL_BASE # Force interval elapsed & high hit ratio pattern (~0.9) _force_interval_elapsed() - pc._RECENT_HITS.clear() # type: ignore[attr-defined] + pc._RECENT_HITS.clear() for _ in range(72): pc.record_request_hit(True) for _ in range(8): diff --git a/code/tests/test_random_rate_limit_headers.py b/code/tests/test_random_rate_limit_headers.py index 6a18061..6fb2e30 100644 --- a/code/tests/test_random_rate_limit_headers.py +++ b/code/tests/test_random_rate_limit_headers.py @@ -19,17 +19,17 @@ def _client_with_flags(window_s: int = 2, limit_random: int = 2, limit_build: in # Force fresh import so RATE_LIMIT_* constants reflect env sys.modules.pop('code.web.app', None) - from code.web import app as app_module # type: ignore + from code.web import app as app_module # Force override constants for deterministic test try: - app_module.RATE_LIMIT_ENABLED = True # type: ignore[attr-defined] - app_module.RATE_LIMIT_WINDOW_S = window_s # type: ignore[attr-defined] - app_module.RATE_LIMIT_RANDOM = limit_random # type: ignore[attr-defined] - app_module.RATE_LIMIT_BUILD = limit_build # type: ignore[attr-defined] - app_module.RATE_LIMIT_SUGGEST = limit_suggest # type: ignore[attr-defined] + app_module.RATE_LIMIT_ENABLED = True + app_module.RATE_LIMIT_WINDOW_S = window_s + app_module.RATE_LIMIT_RANDOM = limit_random + app_module.RATE_LIMIT_BUILD = limit_build + app_module.RATE_LIMIT_SUGGEST = limit_suggest # Reset in-memory counters if hasattr(app_module, '_RL_COUNTS'): - app_module._RL_COUNTS.clear() # type: ignore[attr-defined] + app_module._RL_COUNTS.clear() except Exception: pass return TestClient(app_module.app) diff --git a/code/tests/test_random_theme_stats_diagnostics.py b/code/tests/test_random_theme_stats_diagnostics.py index 5602ba4..5c71326 100644 --- a/code/tests/test_random_theme_stats_diagnostics.py +++ b/code/tests/test_random_theme_stats_diagnostics.py @@ -3,8 +3,8 @@ from pathlib import Path from fastapi.testclient import TestClient -from code.web import app as web_app # type: ignore -from code.web.app import app # type: ignore +from code.web import app as web_app +from code.web.app import app # Ensure project root on sys.path for absolute imports ROOT = Path(__file__).resolve().parents[2] diff --git a/code/tests/test_sampling_unit.py b/code/tests/test_sampling_unit.py index 2f09806..711c856 100644 --- a/code/tests/test_sampling_unit.py +++ b/code/tests/test_sampling_unit.py @@ -9,17 +9,17 @@ def setup_module(module): # ensure deterministic env weights def test_rarity_diminishing(): # Monkeypatch internal index - card_index._CARD_INDEX.clear() # type: ignore + card_index._CARD_INDEX.clear() theme = "Test Theme" - card_index._CARD_INDEX[theme] = [ # type: ignore + card_index._CARD_INDEX[theme] = [ {"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, {"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, ] def no_build(): return None - sampling.maybe_build_index = no_build # type: ignore + sampling.maybe_build_index = no_build cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None) - rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore + rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] assert len(rarity_weights) >= 2 v1 = float(rarity_weights[0].split(":")[-1]) v2 = float(rarity_weights[1].split(":")[-1]) @@ -40,15 +40,15 @@ def test_commander_overlap_monotonic_diminishing(): def test_splash_off_color_penalty_applied(): - card_index._CARD_INDEX.clear() # type: ignore + card_index._CARD_INDEX.clear() theme = "Splash Theme" # Commander W U B R (4 colors) commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"} # Card with single off-color G (W U B R G) splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"} - card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore - sampling.maybe_build_index = lambda: None # type: ignore + card_index._CARD_INDEX[theme] = [commander, splash_card] + sampling.maybe_build_index = lambda: None cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest") splash = next((c for c in cards if c["name"] == "CardSplash"), None) assert splash is not None - assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore + assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) diff --git a/code/tests/test_scryfall_name_normalization.py b/code/tests/test_scryfall_name_normalization.py index cdd7c09..f4a6834 100644 --- a/code/tests/test_scryfall_name_normalization.py +++ b/code/tests/test_scryfall_name_normalization.py @@ -1,5 +1,5 @@ import re -from code.web.services.theme_preview import get_theme_preview # type: ignore +from code.web.services.theme_preview import get_theme_preview # We can't easily execute the JS normalizeCardName in Python, but we can ensure # server-delivered sample names that include appended synergy annotations are not diff --git a/code/tests/test_service_worker_offline.py b/code/tests/test_service_worker_offline.py index 291e3ca..080a6bb 100644 --- a/code/tests/test_service_worker_offline.py +++ b/code/tests/test_service_worker_offline.py @@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") # skip if FastAPI missing def load_app_with_env(**env: str) -> types.ModuleType: for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module # type: ignore + import code.web.app as app_module importlib.reload(app_module) return app_module diff --git a/code/tests/test_theme_api_phase_e.py b/code/tests/test_theme_api_phase_e.py index 0afa5d8..e61252c 100644 --- a/code/tests/test_theme_api_phase_e.py +++ b/code/tests/test_theme_api_phase_e.py @@ -2,7 +2,7 @@ import sys from pathlib import Path import pytest from fastapi.testclient import TestClient -from code.web.app import app # type: ignore +from code.web.app import app # Ensure project root on sys.path for absolute imports ROOT = Path(__file__).resolve().parents[2] diff --git a/code/tests/test_theme_catalog_generation.py b/code/tests/test_theme_catalog_generation.py index 81f6634..9badfc2 100644 --- a/code/tests/test_theme_catalog_generation.py +++ b/code/tests/test_theme_catalog_generation.py @@ -146,7 +146,7 @@ def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> No assert all(row['last_generated_at'] == result.generated_at for row in rows) assert all(row['version'] == result.version for row in rows) - expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined] + expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) assert result.version == expected_hash diff --git a/code/tests/test_theme_catalog_mapping_and_samples.py b/code/tests/test_theme_catalog_mapping_and_samples.py index bc661cf..9cdd9c8 100644 --- a/code/tests/test_theme_catalog_mapping_and_samples.py +++ b/code/tests/test_theme_catalog_mapping_and_samples.py @@ -4,7 +4,7 @@ import os import importlib from pathlib import Path from starlette.testclient import TestClient -from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore +from code.type_definitions_theme_catalog import ThemeCatalog CATALOG_PATH = Path('config/themes/theme_list.json') diff --git a/code/tests/test_theme_catalog_schema_validation.py b/code/tests/test_theme_catalog_schema_validation.py index eb8593b..3bff64c 100644 --- a/code/tests/test_theme_catalog_schema_validation.py +++ b/code/tests/test_theme_catalog_schema_validation.py @@ -8,7 +8,7 @@ def test_theme_list_json_validates_against_pydantic_and_fast_path(): raw = json.loads(p.read_text(encoding='utf-8')) # Pydantic validation - from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore + from code.type_definitions_theme_catalog import ThemeCatalog catalog = ThemeCatalog(**raw) assert isinstance(catalog.themes, list) and len(catalog.themes) > 0 # Basic fields exist on entries diff --git a/code/tests/test_theme_picker_gaps.py b/code/tests/test_theme_picker_gaps.py index 6e7f5c9..0146cce 100644 --- a/code/tests/test_theme_picker_gaps.py +++ b/code/tests/test_theme_picker_gaps.py @@ -36,7 +36,7 @@ from fastapi.testclient import TestClient def _get_app(): # local import to avoid heavy import cost if file unused - from code.web.app import app # type: ignore + from code.web.app import app return app @@ -115,13 +115,13 @@ def test_preview_cache_hit_timing(monkeypatch, client): r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") assert r1.status_code == 200 # Monkeypatch theme_preview._now to freeze time so second call counts as hit - import code.web.services.theme_preview as tp # type: ignore + import code.web.services.theme_preview as tp orig_now = tp._now monkeypatch.setattr(tp, "_now", lambda: orig_now()) r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") assert r2.status_code == 200 # Deterministic service-level verification: second direct function call should short-circuit via cache - import code.web.services.theme_preview as tp # type: ignore + import code.web.services.theme_preview as tp # Snapshot counters pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0) first_payload = tp.get_theme_preview(theme_id, limit=12) diff --git a/code/tests/test_theme_preview_additional.py b/code/tests/test_theme_preview_additional.py index f9a848f..33aff75 100644 --- a/code/tests/test_theme_preview_additional.py +++ b/code/tests/test_theme_preview_additional.py @@ -16,7 +16,7 @@ def _new_client(prewarm: bool = False) -> TestClient: # Remove existing module (if any) so lifespan runs again if 'code.web.app' in list(importlib.sys.modules.keys()): importlib.sys.modules.pop('code.web.app') - from code.web.app import app # type: ignore + from code.web.app import app return TestClient(app) diff --git a/code/tests/test_theme_preview_ordering.py b/code/tests/test_theme_preview_ordering.py index 5cbebdf..f0143f5 100644 --- a/code/tests/test_theme_preview_ordering.py +++ b/code/tests/test_theme_preview_ordering.py @@ -2,8 +2,8 @@ from __future__ import annotations import pytest -from code.web.services.theme_preview import get_theme_preview # type: ignore -from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore +from code.web.services.theme_preview import get_theme_preview +from code.web.services.theme_catalog_loader import load_index, slugify, project_detail @pytest.mark.parametrize("limit", [8, 12]) diff --git a/code/tests/test_theme_preview_p0_new.py b/code/tests/test_theme_preview_p0_new.py index 171893d..a35956f 100644 --- a/code/tests/test_theme_preview_p0_new.py +++ b/code/tests/test_theme_preview_p0_new.py @@ -1,7 +1,7 @@ import os import time import json -from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore +from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache def test_colors_filter_constraint_green_subset(): diff --git a/code/tests/test_theme_spell_weighting.py b/code/tests/test_theme_spell_weighting.py index e95d60b..637940a 100644 --- a/code/tests/test_theme_spell_weighting.py +++ b/code/tests/test_theme_spell_weighting.py @@ -47,10 +47,10 @@ class DummySpellBuilder(SpellAdditionMixin): def rng(self) -> DummyRNG: return self._rng - def get_theme_context(self) -> ThemeContext: # type: ignore[override] + def get_theme_context(self) -> ThemeContext: return self._theme_context - def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override] + def add_card(self, name: str, **kwargs: Any) -> None: self.card_library[name] = {"Count": kwargs.get("count", 1)} self.added_cards.append(name) diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py index 703dd9f..655f081 100644 --- a/code/tests/test_web_new_deck_partner.py +++ b/code/tests/test_web_new_deck_partner.py @@ -20,7 +20,7 @@ def _fresh_client() -> TestClient: from code.web.services.commander_catalog_loader import clear_commander_catalog_cache clear_commander_catalog_cache() - from code.web.app import app # type: ignore + from code.web.app import app client = TestClient(app) from code.web.services import tasks diff --git a/code/type_definitions_theme_catalog.py b/code/type_definitions_theme_catalog.py index da88ae0..dbcae13 100644 --- a/code/type_definitions_theme_catalog.py +++ b/code/type_definitions_theme_catalog.py @@ -87,7 +87,7 @@ class ThemeCatalog(BaseModel): def theme_names(self) -> List[str]: # convenience return [t.theme for t in self.themes] - def model_post_init(self, __context: Any) -> None: # type: ignore[override] + def model_post_init(self, __context: Any) -> None: # If only legacy 'provenance' provided, alias to metadata_info if self.metadata_info is None and self.provenance is not None: object.__setattr__(self, 'metadata_info', self.provenance) @@ -135,7 +135,7 @@ class ThemeYAMLFile(BaseModel): model_config = ConfigDict(extra='forbid') - def model_post_init(self, __context: Any) -> None: # type: ignore[override] + def model_post_init(self, __context: Any) -> None: if not self.metadata_info and self.provenance: object.__setattr__(self, 'metadata_info', self.provenance) if self.metadata_info and self.provenance: diff --git a/code/web/app.py b/code/web/app.py index ac2854b..77f4f7c 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -19,9 +19,12 @@ from contextlib import asynccontextmanager from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics from tagging.multi_face_merger import load_merge_summary from .services.combo_utils import detect_all as _detect_all -from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore -from .services.commander_catalog_loader import load_commander_catalog # type: ignore -from .services.tasks import get_session, new_sid, set_session_value # type: ignore +from .services.theme_catalog_loader import prewarm_common_filters, load_index +from .services.commander_catalog_loader import load_commander_catalog +from .services.tasks import get_session, new_sid, set_session_value + +# Logger for app-level logging +logger = logging.getLogger(__name__) # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -53,18 +56,18 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue except Exception: pass try: - commanders_routes.prewarm_default_page() # type: ignore[attr-defined] + commanders_routes.prewarm_default_page() except Exception: pass # Warm preview card index once (updated Phase A: moved to card_index module) try: # local import to avoid cost if preview unused - from .services.card_index import maybe_build_index # type: ignore + from .services.card_index import maybe_build_index maybe_build_index() except Exception: pass # Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing) try: - from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore + from .routes.card_browser import get_theme_catalog, get_theme_index get_theme_catalog() # Fast: just reads CSV get_theme_index() # Slower: parses cards for theme-to-card mapping except Exception: @@ -73,7 +76,7 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue try: from code.settings import ENABLE_CARD_DETAILS if ENABLE_CARD_DETAILS: - from .routes.card_browser import get_similarity # type: ignore + from .routes.card_browser import get_similarity get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) except Exception: pass @@ -86,7 +89,7 @@ app.add_middleware(GZipMiddleware, minimum_size=500) # Mount static if present if _STATIC_DIR.exists(): class CacheStatic(StaticFiles): - async def get_response(self, path, scope): # type: ignore[override] + async def get_response(self, path, scope): resp = await super().get_response(path, scope) try: # Add basic cache headers for static assets @@ -99,12 +102,38 @@ if _STATIC_DIR.exists(): # Jinja templates templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) +# Add custom Jinja2 filter for card image URLs +def card_image_url(card_name: str, size: str = "normal") -> str: + """ + Generate card image URL (uses local cache if available, falls back to Scryfall). + + For DFC cards (containing ' // '), extracts the front face name. + + Args: + card_name: Name of the card (may be "Front // Back" for DFCs) + size: Image size ('small' or 'normal') + + Returns: + URL for the card image + """ + from urllib.parse import quote + + # Extract front face name for DFCs (thumbnails always show front face) + display_name = card_name + if ' // ' in card_name: + display_name = card_name.split(' // ')[0].strip() + + # Use our API endpoint which handles cache lookup and fallback + return f"/api/images/{size}/{quote(display_name)}" + +templates.env.filters["card_image"] = card_image_url + # Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...}) # and reorder to the new signature TemplateResponse(request, name, {...}). # Prevents DeprecationWarning noise in tests without touching all call sites. _orig_template_response = templates.TemplateResponse -def _compat_template_response(*args, **kwargs): # type: ignore[override] +def _compat_template_response(*args, **kwargs): try: if args and isinstance(args[0], str): name = args[0] @@ -122,7 +151,7 @@ def _compat_template_response(*args, **kwargs): # type: ignore[override] pass return _orig_template_response(*args, **kwargs) -templates.TemplateResponse = _compat_template_response # type: ignore[assignment] +templates.TemplateResponse = _compat_template_response # (Startup prewarm moved to lifespan handler _lifespan) @@ -298,7 +327,7 @@ templates.env.globals.update({ # Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev' def _load_catalog_hash() -> str: try: # local import to avoid circular on early load - from .services.theme_catalog_loader import CATALOG_JSON # type: ignore + from .services.theme_catalog_loader import CATALOG_JSON if CATALOG_JSON.exists(): raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") meta = raw.get("metadata_info") or {} @@ -840,6 +869,12 @@ async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")}) +@app.get("/docs/components", response_class=HTMLResponse) +async def components_library(request: Request) -> HTMLResponse: + """M2 Component Library - showcase of standardized UI components""" + return templates.TemplateResponse("docs/components.html", {"request": request}) + + # Simple health check (hardened) @app.get("/healthz") async def healthz(): @@ -916,7 +951,7 @@ async def status_random_theme_stats(): if not SHOW_DIAGNOSTICS: raise HTTPException(status_code=404, detail="Not Found") try: - from deck_builder.random_entrypoint import get_theme_tag_stats # type: ignore + from deck_builder.random_entrypoint import get_theme_tag_stats stats = get_theme_tag_stats() return JSONResponse({"ok": True, "stats": stats}) @@ -1003,8 +1038,8 @@ async def api_random_build(request: Request): except Exception: timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) # Import on-demand to avoid heavy costs at module import time - from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore - from deck_builder.random_entrypoint import RandomThemeNoMatchError # type: ignore + from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError + from deck_builder.random_entrypoint import RandomThemeNoMatchError res = build_random_deck( theme=theme, @@ -1135,7 +1170,7 @@ async def api_random_full_build(request: Request): timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) # Build a full deck deterministically - from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore + from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1359,7 +1394,7 @@ async def api_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed # type: ignore + from random_util import generate_seed new_seed = int(generate_seed()) # Build with the new seed @@ -1370,7 +1405,7 @@ async def api_random_reroll(request: Request): timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS)) - from deck_builder.random_entrypoint import build_random_full_deck # type: ignore + from deck_builder.random_entrypoint import build_random_full_deck res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1751,10 +1786,10 @@ async def hx_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed # type: ignore + from random_util import generate_seed new_seed = int(generate_seed()) # Import outside conditional to avoid UnboundLocalError when branch not taken - from deck_builder.random_entrypoint import build_random_full_deck # type: ignore + from deck_builder.random_entrypoint import build_random_full_deck try: t0 = time.time() _attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS) @@ -1765,7 +1800,7 @@ async def hx_random_reroll(request: Request): _timeout_s = max(0.1, float(_timeout_ms) / 1000.0) if is_reroll_same: build_t0 = time.time() - from headless_runner import run as _run # type: ignore + from headless_runner import run as _run # Suppress builder's internal initial export to control artifact generation (matches full random path logic) try: import os as _os @@ -1778,18 +1813,18 @@ async def hx_random_reroll(request: Request): summary = None try: if hasattr(builder, 'build_deck_summary'): - summary = builder.build_deck_summary() # type: ignore[attr-defined] + summary = builder.build_deck_summary() except Exception: summary = None decklist = [] try: if hasattr(builder, 'deck_list_final'): - decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined] + decklist = getattr(builder, 'deck_list_final') except Exception: decklist = [] # Controlled artifact export (single pass) - csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] - txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] + csv_path = getattr(builder, 'last_csv_path', None) + txt_path = getattr(builder, 'last_txt_path', None) compliance = None try: import os as _os @@ -1797,7 +1832,7 @@ async def hx_random_reroll(request: Request): # Perform exactly one export sequence now if not csv_path and hasattr(builder, 'export_decklist_csv'): try: - csv_path = builder.export_decklist_csv() # type: ignore[attr-defined] + csv_path = builder.export_decklist_csv() except Exception: csv_path = None if csv_path and isinstance(csv_path, str): @@ -1807,7 +1842,7 @@ async def hx_random_reroll(request: Request): try: base_name = _os.path.basename(base_path) + '.txt' if hasattr(builder, 'export_decklist_text'): - txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined] + txt_path = builder.export_decklist_text(filename=base_name) except Exception: # Fallback: if a txt already exists from a prior build reuse it if _os.path.isfile(base_path + '.txt'): @@ -1822,7 +1857,7 @@ async def hx_random_reroll(request: Request): else: try: if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) except Exception: compliance = None if summary: @@ -2016,7 +2051,7 @@ async def hx_random_reroll(request: Request): except Exception: _permalink = None resp = templates.TemplateResponse( - "partials/random_result.html", # type: ignore + "partials/random_result.html", { "request": request, "seed": int(res.seed), @@ -2212,6 +2247,13 @@ async def setup_status(): return JSONResponse({"running": False, "phase": "error"}) +# ============================================================================ +# Card Image Serving Endpoint - MOVED TO /routes/api.py +# ============================================================================ +# Image serving logic has been moved to code/web/routes/api.py +# The router is included below via: app.include_router(api_routes.router) + + # Routers from .routes import build as build_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402 @@ -2225,6 +2267,7 @@ from .routes import telemetry as telemetry_routes # noqa: E402 from .routes import cards as cards_routes # noqa: E402 from .routes import card_browser as card_browser_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402 +from .routes import api as api_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2237,6 +2280,7 @@ app.include_router(telemetry_routes.router) app.include_router(cards_routes.router) app.include_router(card_browser_routes.router) app.include_router(compare_routes.router) +app.include_router(api_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: @@ -2423,7 +2467,7 @@ async def logs_page( # Respect feature flag raise HTTPException(status_code=404, detail="Not Found") # Reuse status_logs logic - data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type] + data = await status_logs(tail=tail, q=q, level=level) lines: list[str] if isinstance(data, JSONResponse): payload = data.body diff --git a/code/web/routes/api.py b/code/web/routes/api.py new file mode 100644 index 0000000..157344b --- /dev/null +++ b/code/web/routes/api.py @@ -0,0 +1,299 @@ +"""API endpoints for web services.""" + +from __future__ import annotations + +import logging +import threading +from pathlib import Path +from urllib.parse import quote_plus + +from fastapi import APIRouter, Query +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from code.file_setup.image_cache import ImageCache + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + +# Global image cache instance +_image_cache = ImageCache() + + +@router.get("/images/status") +async def get_download_status(): + """ + Get current image download status. + + Returns: + JSON response with download status + """ + import json + + status_file = Path("card_files/images/.download_status.json") + + if not status_file.exists(): + # Check cache statistics if no download in progress + stats = _image_cache.cache_statistics() + return JSONResponse({ + "running": False, + "stats": stats + }) + + try: + with status_file.open('r', encoding='utf-8') as f: + status = json.load(f) + return JSONResponse(status) + except Exception as e: + logger.warning(f"Could not read status file: {e}") + return JSONResponse({ + "running": False, + "error": str(e) + }) + + +@router.get("/images/debug") +async def get_image_debug(): + """ + Debug endpoint to check image cache configuration. + + Returns: + JSON with debug information + """ + import os + from pathlib import Path + + base_dir = Path(_image_cache.base_dir) + + debug_info = { + "cache_enabled": _image_cache.is_enabled(), + "env_var": os.getenv("CACHE_CARD_IMAGES", "not set"), + "base_dir": str(base_dir), + "base_dir_exists": base_dir.exists(), + "small_dir": str(base_dir / "small"), + "small_dir_exists": (base_dir / "small").exists(), + "normal_dir": str(base_dir / "normal"), + "normal_dir_exists": (base_dir / "normal").exists(), + } + + # Count files if directories exist + if (base_dir / "small").exists(): + debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg"))) + if (base_dir / "normal").exists(): + debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg"))) + + # Test with a sample card name + test_card = "Lightning Bolt" + debug_info["test_card"] = test_card + test_path_small = _image_cache.get_image_path(test_card, "small") + test_path_normal = _image_cache.get_image_path(test_card, "normal") + debug_info["test_path_small"] = str(test_path_small) if test_path_small else None + debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None + debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False + debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False + + return JSONResponse(debug_info) + + +@router.get("/images/{size}/{card_name}") +async def get_card_image(size: str, card_name: str, face: str = Query(default="front")): + """ + Serve card image from cache or redirect to Scryfall API. + + Args: + size: Image size ('small' or 'normal') + card_name: Name of the card + face: Which face to show ('front' or 'back') for DFC cards + + Returns: + FileResponse if cached locally, RedirectResponse to Scryfall API otherwise + """ + # Validate size parameter + if size not in ["small", "normal"]: + size = "normal" + + # Check if caching is enabled + cache_enabled = _image_cache.is_enabled() + + # Check if image exists in cache + if cache_enabled: + image_path = None + + # For DFC cards, handle front/back faces differently + if " // " in card_name: + if face == "back": + # For back face, ONLY try the back face name + back_face = card_name.split(" // ")[1].strip() + logger.debug(f"DFC back face requested: {back_face}") + image_path = _image_cache.get_image_path(back_face, size) + else: + # For front face (or unspecified), try front face name + front_face = card_name.split(" // ")[0].strip() + logger.debug(f"DFC front face requested: {front_face}") + image_path = _image_cache.get_image_path(front_face, size) + else: + # Single-faced card, try exact name + image_path = _image_cache.get_image_path(card_name, size) + + if image_path and image_path.exists(): + logger.info(f"Serving cached image: {card_name} ({size}, {face})") + return FileResponse( + image_path, + media_type="image/jpeg", + headers={ + "Cache-Control": "public, max-age=31536000", # 1 year + } + ) + else: + logger.debug(f"No cached image found for: {card_name} (face: {face})") + + # Fallback to Scryfall API + # For back face requests of DFC cards, we need the full card name + scryfall_card_name = card_name + scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}" + + # If this is a back face request, try to find the full DFC name + if face == "back": + try: + from code.services.all_cards_loader import AllCardsLoader + loader = AllCardsLoader() + df = loader.load() + + # Look for cards where this face name appears in the card_faces + # The card name format is "Front // Back" + matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)] + if not matching.empty: + # Find DFC cards (containing ' // ') + dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)] + if not dfc_matches.empty: + # Use the first matching DFC card's full name + full_name = dfc_matches.iloc[0]['name'] + scryfall_card_name = full_name + # Add face parameter to Scryfall request + scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back" + except Exception as e: + logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}") + + scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}" + return RedirectResponse(scryfall_url) + + +@router.post("/images/download") +async def download_images(): + """ + Start downloading card images in background. + + Returns: + JSON response with status + """ + if not _image_cache.is_enabled(): + return JSONResponse({ + "ok": False, + "message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable." + }, status_code=400) + + # Write initial status + try: + status_dir = Path("card_files/images") + status_dir.mkdir(parents=True, exist_ok=True) + status_file = status_dir / ".download_status.json" + + import json + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "bulk_data", + "message": "Downloading Scryfall bulk data...", + "current": 0, + "total": 0, + "percentage": 0 + }, f) + except Exception as e: + logger.warning(f"Could not write initial status: {e}") + + # Start download in background thread + def _download_task(): + import json + status_file = Path("card_files/images/.download_status.json") + + try: + # Download bulk data first + logger.info("[IMAGE DOWNLOAD] Starting bulk data download...") + + def bulk_progress(downloaded: int, total: int): + """Progress callback for bulk data download.""" + try: + percentage = int(downloaded / total * 100) if total > 0 else 0 + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "bulk_data", + "message": f"Downloading bulk data: {percentage}%", + "current": downloaded, + "total": total, + "percentage": percentage + }, f) + except Exception as e: + logger.warning(f"Could not update bulk progress: {e}") + + _image_cache.download_bulk_data(progress_callback=bulk_progress) + + # Download images + logger.info("[IMAGE DOWNLOAD] Starting image downloads...") + + def image_progress(current: int, total: int, card_name: str): + """Progress callback for image downloads.""" + try: + percentage = int(current / total * 100) if total > 0 else 0 + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "images", + "message": f"Downloading images: {card_name}", + "current": current, + "total": total, + "percentage": percentage + }, f) + + # Log progress every 100 cards + if current % 100 == 0: + logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)") + + except Exception as e: + logger.warning(f"Could not update image progress: {e}") + + stats = _image_cache.download_images(progress_callback=image_progress) + + # Write completion status + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": False, + "phase": "complete", + "message": f"Download complete: {stats.get('downloaded', 0)} new images", + "stats": stats, + "percentage": 100 + }, f) + + logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}") + + except Exception as e: + logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True) + try: + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": False, + "phase": "error", + "message": f"Download failed: {str(e)}", + "percentage": 0 + }, f) + except Exception: + pass + + # Start background thread + thread = threading.Thread(target=_download_task, daemon=True) + thread.start() + + return JSONResponse({ + "ok": True, + "message": "Image download started in background" + }, status_code=202) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 5a80829..c9c9090 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -25,11 +25,12 @@ from ..services.build_utils import ( owned_set as owned_set_helper, builder_present_names, builder_display_map, + commander_hover_context, ) from ..app import templates from deck_builder import builder_constants as bc from ..services import orchestrator as orch -from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore +from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale from ..services.build_utils import owned_names as owned_names_helper from ..services.tasks import get_session, new_sid from html import escape as _esc @@ -118,7 +119,7 @@ def _available_cards_normalized() -> tuple[set[str], dict[str, str]]: from deck_builder.include_exclude_utils import normalize_punctuation except Exception: # Fallback: identity normalization - def normalize_punctuation(x: str) -> str: # type: ignore + def normalize_punctuation(x: str) -> str: return str(x).strip().casefold() norm_map: dict[str, str] = {} for name in names: @@ -469,7 +470,7 @@ def _background_options_from_commander_catalog() -> list[dict[str, Any]]: seen: set[str] = set() options: list[dict[str, Any]] = [] - for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined] + for record in getattr(catalog, "entries", ()): if not getattr(record, "is_background", False): continue name = getattr(record, "display_name", None) @@ -1107,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse: if q_commander: # Persist a human-friendly commander name into session for the wizard sess["commander"] = str(q_commander) + # Set flag to indicate this is a quick-build scenario + sess["quick_build"] = True except Exception: pass return_url = None @@ -1146,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse: last_step = 2 else: last_step = 1 + # Only pass commander to template if coming from commander browser (?commander= query param) + # This prevents stale commander from being pre-filled on subsequent builds + # The query param only exists on initial navigation from commander browser + should_auto_fill = q_commander is not None + resp = templates.TemplateResponse( request, "build/index.html", { "sid": sid, - "commander": sess.get("commander"), + "commander": sess.get("commander") if should_auto_fill else None, "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, @@ -1349,6 +1357,19 @@ async def build_new_modal(request: Request) -> HTMLResponse: for key in skip_keys: sess.pop(key, None) + # M2: Check if this is a quick-build scenario (from commander browser) + # Use the quick_build flag set by /build route when ?commander= param present + is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag + + # M2: Clear commander and form selections for fresh start (unless quick build) + if not is_quick_build: + commander_keys = [ + "commander", "partner", "background", "commander_mode", + "themes", "bracket" + ] + for key in commander_keys: + sess.pop(key, None) + theme_context = _custom_theme_context(request, sess) ctx = { "request": request, @@ -1361,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "enable_batch_build": ENABLE_BATCH_BUILD, "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "form": { + "commander": sess.get("commander", ""), # Pre-fill for quick-build "prefer_combos": bool(sess.get("prefer_combos")), "combo_count": sess.get("combo_target_count"), "combo_balance": sess.get("combo_balance"), @@ -1483,20 +1505,14 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes merged_tags.append(token) ctx["tags"] = merged_tags + # Deduplicate recommended: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} existing_recommended = ctx.get("recommended") or [] - merged_recommended: list[str] = [] - rec_seen: set[str] = set() - for source in (partner_tags, existing_recommended): - for tag in source: - token = str(tag).strip() - if not token: - continue - key = token.casefold() - if key in rec_seen: - continue - rec_seen.add(key) - merged_recommended.append(token) - ctx["recommended"] = merged_recommended + deduplicated_recommended = [ + tag for tag in existing_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + ctx["recommended"] = deduplicated_recommended reason_map = dict(ctx.get("recommended_reasons") or {}) for tag in partner_tags: @@ -2849,7 +2865,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo snap = h.get("snapshot") break if snap is not None: - orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] + orch._restore_builder(ctx["builder"], snap) ctx["idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1 except Exception: @@ -2907,6 +2923,11 @@ async def build_step2_get(request: Request) -> HTMLResponse: if is_gc and (sel_br is None or int(sel_br) < 3): sel_br = 3 partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) + + import logging + logger = logging.getLogger(__name__) + logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") + context = { "request": request, "commander": {"name": commander}, @@ -2940,7 +2961,22 @@ async def build_step2_get(request: Request) -> HTMLResponse: ) partner_tags = context.pop("partner_theme_tags", None) if partner_tags: + import logging + logger = logging.getLogger(__name__) context["tags"] = partner_tags + # Deduplicate recommended tags: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} + original_recommended = context.get("recommended", []) + deduplicated_recommended = [ + tag for tag in original_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + logger.info( + f"Step2: partner_tags={len(partner_tags)}, " + f"original_recommended={len(original_recommended)}, " + f"deduplicated_recommended={len(deduplicated_recommended)}" + ) + context["recommended"] = deduplicated_recommended resp = templates.TemplateResponse("build/_step2.html", context) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -3266,6 +3302,57 @@ async def build_step3_get(request: Request) -> HTMLResponse: sess["last_step"] = 3 defaults = orch.ideal_defaults() values = sess.get("ideals") or defaults + + # Check if any skip flags are enabled to show skeleton automation page + skip_flags = { + "skip_lands": "land selection", + "skip_to_misc": "land selection", + "skip_basics": "basic lands", + "skip_staples": "staple lands", + "skip_kindred": "kindred lands", + "skip_fetches": "fetch lands", + "skip_duals": "dual lands", + "skip_triomes": "triome lands", + "skip_all_creatures": "creature selection", + "skip_creature_primary": "primary creatures", + "skip_creature_secondary": "secondary creatures", + "skip_creature_fill": "creature fills", + "skip_all_spells": "spell selection", + "skip_ramp": "ramp spells", + "skip_removal": "removal spells", + "skip_wipes": "board wipes", + "skip_card_advantage": "card advantage spells", + "skip_protection": "protection spells", + "skip_spell_fill": "spell fills", + } + + active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)] + + if active_skips: + # Show skeleton automation page with auto-submit + automation_parts = [] + if any("land" in s for s in active_skips): + automation_parts.append("lands") + if any("creature" in s for s in active_skips): + automation_parts.append("creatures") + if any("spell" in s for s in active_skips): + automation_parts.append("spells") + + automation_message = f"Applying default values for {', '.join(automation_parts)}..." + + resp = templates.TemplateResponse( + "build/_step3_skeleton.html", + { + "request": request, + "defaults": defaults, + "commander": sess.get("commander"), + "automation_message": automation_message, + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # No skips enabled, show normal form resp = templates.TemplateResponse( "build/_step3.html", { @@ -3782,7 +3869,7 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse: if not ctx or not ctx.get("snapshot"): return await build_step5_get(request) try: - orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] + orch._restore_builder(ctx["builder"], ctx["snapshot"]) except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list @@ -3844,6 +3931,16 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe ctx["synergies"] = synergies ctx["summary_ready"] = True ctx["summary_token"] = active_token + + # Add commander hover context for color identity and theme tags + hover_meta = commander_hover_context( + commander_name=ctx.get("commander"), + deck_tags=sess.get("tags"), + summary=summary_data, + combined=ctx.get("combined_commander"), + ) + ctx.update(hover_meta) + response = templates.TemplateResponse("partials/deck_summary.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") return response @@ -4196,7 +4293,7 @@ async def build_alternatives( try: if rng is not None: return rng.sample(seq, limit) if len(seq) >= limit else list(seq) - import random as _rnd # type: ignore + import random as _rnd return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) except Exception: return list(seq[:limit]) @@ -4247,7 +4344,7 @@ async def build_alternatives( # Helper: map display names def _display_map_for(lower_pool: set[str]) -> dict[str, str]: try: - return builder_display_map(b, lower_pool) # type: ignore[arg-type] + return builder_display_map(b, lower_pool) except Exception: return {nm: nm for nm in lower_pool} @@ -4425,7 +4522,7 @@ async def build_alternatives( pass # Sort by priority like the builder try: - pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] + pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) except Exception: pass # Exclusions and ownership (for non-random roles this stays before slicing) @@ -4923,13 +5020,13 @@ async def build_compliance_panel(request: Request) -> HTMLResponse: comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp = b.compute_and_print_compliance(base_stem=None) except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] + comp = orch._attach_enforcement_plan(b, comp) except Exception: pass if not comp: @@ -5054,11 +5151,11 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: # If missing, export once to establish base if not base_stem: try: - ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] + ctx["csv_path"] = b.export_decklist_csv() import os as _os base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] # Also produce a text export for completeness - ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] + ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') except Exception: base_stem = None # Add lock placeholders into the library before enforcement so user choices are present @@ -5103,7 +5200,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: pass # Run enforcement + re-exports (tops up to 100 internally) try: - rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] + rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') except Exception as e: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) @@ -5177,13 +5274,13 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse: comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp = b.compute_and_print_compliance(base_stem=None) except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] + comp = orch._attach_enforcement_plan(b, comp) except Exception: pass try: diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 957936b..9b4f290 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -425,7 +425,7 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s mt_val = str(int(mt)) except Exception: mt_val = "0" - options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type] + options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) diffs = None metaA: Dict[str, str] = {} diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index 9cbe635..dc711d4 100644 --- a/code/web/routes/setup.py +++ b/code/web/routes/setup.py @@ -7,7 +7,7 @@ from pathlib import Path import json as _json from fastapi.responses import HTMLResponse, JSONResponse from ..app import templates -from ..services.orchestrator import _ensure_setup_ready # type: ignore +from ..services.orchestrator import _ensure_setup_ready router = APIRouter(prefix="/setup") @@ -21,7 +21,7 @@ def _kickoff_setup_async(force: bool = False): def runner(): try: print(f"[SETUP THREAD] Starting setup/tagging (force={force})...") - _ensure_setup_ready(print, force=force) # type: ignore[arg-type] + _ensure_setup_ready(print, force=force) print("[SETUP THREAD] Setup/tagging completed successfully") except Exception as e: # pragma: no cover - background best effort try: @@ -36,7 +36,7 @@ def _kickoff_setup_async(force: bool = False): @router.get("/running", response_class=HTMLResponse) -async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override] +async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # Optionally start the setup/tagging in the background if requested try: if start and int(start) != 0: @@ -195,7 +195,11 @@ async def download_github(): @router.get("/", response_class=HTMLResponse) async def setup_index(request: Request) -> HTMLResponse: import code.settings as settings + from code.file_setup.image_cache import ImageCache + + image_cache = ImageCache() return templates.TemplateResponse("setup/index.html", { "request": request, - "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES + "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES, + "image_cache_enabled": image_cache.is_enabled() }) diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py index 32cb279..4917aa7 100644 --- a/code/web/routes/themes.py +++ b/code/web/routes/themes.py @@ -7,7 +7,7 @@ from typing import Optional, Dict, Any from fastapi import APIRouter, Request, HTTPException, Query from fastapi import BackgroundTasks -from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore +from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment from fastapi.responses import JSONResponse, HTMLResponse from fastapi.templating import Jinja2Templates from ..services.theme_catalog_loader import ( @@ -17,10 +17,10 @@ from ..services.theme_catalog_loader import ( filter_slugs_fast, summaries_for_slugs, ) -from ..services.theme_preview import get_theme_preview # type: ignore -from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore -from ..services.theme_preview import preview_metrics # type: ignore -from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters +from ..services.theme_preview import get_theme_preview +from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters +from ..services.theme_preview import preview_metrics +from ..services import theme_preview as _theme_preview_mod # for error counters import os from fastapi import Body @@ -36,7 +36,7 @@ router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status # Reuse the main app's template environment so nav globals stay consistent. try: # circular-safe import: app defines templates before importing this router - from ..app import templates as _templates # type: ignore + from ..app import templates as _templates except Exception: # Fallback (tests/minimal contexts) _templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates')) @@ -131,7 +131,7 @@ async def theme_suggest( # Optional rate limit using app helper if available rl_result = None try: - from ..app import rate_limit_check # type: ignore + from ..app import rate_limit_check rl_result = rate_limit_check(request, "suggest") except HTTPException as http_ex: # propagate 429 with headers raise http_ex @@ -231,7 +231,7 @@ async def theme_status(): yaml_file_count = 0 if yaml_catalog_exists: try: - yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type] + yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) except Exception: yaml_file_count = -1 tagged_time = _load_tag_flag_time() @@ -291,28 +291,6 @@ def _diag_enabled() -> bool: return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"} -@router.get("/picker", response_class=HTMLResponse) -async def theme_picker_page(request: Request): - """Render the theme picker shell. - - Dynamic data (list, detail) loads via fragment endpoints. We still inject - known archetype list for the filter select so it is populated on initial load. - """ - archetypes: list[str] = [] - try: - idx = load_index() - archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type] - except Exception: - archetypes = [] - return _templates.TemplateResponse( - "themes/picker.html", - { - "request": request, - "archetypes": archetypes, - "theme_picker_diagnostics": _diag_enabled(), - }, - ) - @router.get("/metrics") async def theme_metrics(): if not _diag_enabled(): @@ -569,7 +547,7 @@ async def theme_yaml(theme_id: str): raise HTTPException(status_code=404, detail="yaml_not_found") # Reconstruct minimal YAML (we have dict already) import yaml as _yaml # local import to keep top-level lean - text = _yaml.safe_dump(y, sort_keys=False) # type: ignore + text = _yaml.safe_dump(y, sort_keys=False) headers = {"Content-Type": "text/plain; charset=utf-8"} return HTMLResponse(text, headers=headers) @@ -653,7 +631,7 @@ async def api_theme_search( prefix: list[dict[str, Any]] = [] substr: list[dict[str, Any]] = [] seen: set[str] = set() - themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined] + themes_iter = list(idx.catalog.themes) # Phase 1 + 2: exact / prefix for t in themes_iter: name = t.theme @@ -746,89 +724,9 @@ async def api_theme_preview( return JSONResponse({"ok": True, "preview": payload}) -@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse) -async def theme_preview_fragment( - theme_id: str, - limit: int = Query(12, ge=1, le=30), - colors: str | None = None, - commander: str | None = None, - suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"), - minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale – used in detail page collapsible preview)"), - request: Request = None, -): - """Return HTML fragment for theme preview with caching headers. - Adds ETag and Last-Modified headers (no strong caching – enables conditional GET / 304). - ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander). - """ - try: - payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander) - except KeyError: - return HTMLResponse("
Theme not found.
", status_code=404) - # Load example commanders (authoritative list) from catalog detail for legality instead of inferring - example_commanders: list[str] = [] - synergy_commanders: list[str] = [] - try: - idx = load_index() - slug = slugify(theme_id) - entry = idx.slug_to_entry.get(slug) - if entry: - detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False) - example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)] - synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)] - # De-duplicate any overlap with example commanders while preserving order - seen = set(example_commanders) - for c in synergy_commanders_raw: - if c not in seen: - synergy_commanders.append(c) - seen.add(c) - except Exception: - example_commanders = [] - synergy_commanders = [] - # Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift) - import hashlib - import json as _json - import time as _time - try: - idx = load_index() - catalog_tag = idx.etag - except Exception: - catalog_tag = "unknown" - hash_src = _json.dumps({ - "theme": theme_id, - "limit": limit, - "commander": commander, - "sample": payload.get("sample", [])[:3], # small slice for stability & speed - "v": 1, - }, sort_keys=True).encode("utf-8") - etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}" - # Conditional request support - if request is not None: - inm = request.headers.get("if-none-match") - if inm and inm == etag: - # 304 Not Modified – FastAPI HTMLResponse with empty body & headers - resp = HTMLResponse(status_code=304, content="") - resp.headers["ETag"] = etag - from email.utils import formatdate as _fmtdate - resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) - resp.headers["Cache-Control"] = "no-cache" - return resp - ctx = { - "request": request, - "preview": payload, - "example_commanders": example_commanders, - "synergy_commanders": synergy_commanders, - "theme_id": theme_id, - "etag": etag, - "suppress_curated": suppress_curated, - "minimal": minimal, - } - resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx) - resp.headers["ETag"] = etag - from email.utils import formatdate as _fmtdate - resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) - resp.headers["Cache-Control"] = "no-cache" - return resp + +@router.get("/fragment/list", response_class=HTMLResponse) # --- Preview Export Endpoints (CSV / JSON) --- diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index a37a540..8c11c56 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -202,7 +202,7 @@ def commander_hover_context( from .summary_utils import format_theme_label, format_theme_list except Exception: # Fallbacks in the unlikely event of circular import issues - def format_theme_label(value: Any) -> str: # type: ignore[redef] + def format_theme_label(value: Any) -> str: text = str(value or "").strip().replace("_", " ") if not text: return "" @@ -214,10 +214,10 @@ def commander_hover_context( parts.append(chunk[:1].upper() + chunk[1:].lower()) return " ".join(parts) - def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef] + def format_theme_list(values: Iterable[Any]) -> list[str]: seen: set[str] = set() result: list[str] = [] - for raw in values or []: # type: ignore[arg-type] + for raw in values or []: label = format_theme_label(raw) if not label or len(label) <= 1: continue @@ -310,13 +310,30 @@ def commander_hover_context( raw_color_identity = combined_info.get("color_identity") if combined_info else None commander_color_identity: list[str] = [] + + # If we have a combined commander (partner/background), use its color identity if isinstance(raw_color_identity, (list, tuple, set)): for item in raw_color_identity: token = str(item).strip().upper() if token: commander_color_identity.append(token) - # M7: For non-partner commanders, also check summary.colors for color identity + # For regular commanders (no partner/background), look up from commander catalog first + if not commander_color_identity and not has_combined and commander_name: + try: + from .commander_catalog_loader import find_commander_record + record = find_commander_record(commander_name) + if record and hasattr(record, 'color_identity'): + raw_ci = record.color_identity + if isinstance(raw_ci, (list, tuple, set)): + for item in raw_ci: + token = str(item).strip().upper() + if token: + commander_color_identity.append(token) + except Exception: + pass + + # Fallback: check summary.colors if we still don't have color identity if not commander_color_identity and not has_combined and isinstance(summary, dict): summary_colors = summary.get("colors") if isinstance(summary_colors, (list, tuple, set)): @@ -403,7 +420,7 @@ def step5_ctx_from_result( else: entry = {} try: - entry.update(vars(item)) # type: ignore[arg-type] + entry.update(vars(item)) except Exception: pass # Preserve common attributes when vars() empty diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index c38b78d..654d5ac 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -359,7 +359,7 @@ def _global_prune_disallowed_pool(b: DeckBuilder) -> None: drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd)) mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd # type: ignore + import pandas as _pd mask_keep = _pd.Series(mask_keep, index=work.index) except Exception: pass @@ -480,7 +480,7 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li tmp = DeckBuilder() try: if hasattr(tmp, '_normalize_commander_query'): - query = tmp._normalize_commander_query(query) # type: ignore[attr-defined] + query = tmp._normalize_commander_query(query) else: # Light fallback: basic title case query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')]) @@ -653,7 +653,7 @@ def commander_select(name: str) -> Dict[str, Any]: if row.empty: try: if hasattr(tmp, '_normalize_commander_query'): - name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined] + name2 = tmp._normalize_commander_query(name) else: name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')]) row = df[df["name"] == name2] @@ -1288,8 +1288,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None: pass # Bust theme-related in-memory caches so new catalog reflects immediately try: - from .theme_catalog_loader import bust_filter_cache # type: ignore - from .theme_preview import bust_preview_cache # type: ignore + from .theme_catalog_loader import bust_filter_cache + from .theme_preview import bust_preview_cache bust_filter_cache("catalog_refresh") bust_preview_cache("catalog_refresh") try: @@ -1327,7 +1327,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: try: # M4 (Parquet Migration): Check for processed Parquet file instead of CSV - from path_util import get_processed_cards_path # type: ignore + from path_util import get_processed_cards_path cards_path = get_processed_cards_path() flag_path = os.path.join('csv_files', '.tagging_complete.json') auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1') @@ -1416,7 +1416,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: _write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0}) try: - from file_setup.setup import initial_setup # type: ignore + from file_setup.setup import initial_setup # Always run initial_setup when forced or when cards are missing/stale initial_setup() except Exception as e: @@ -1425,7 +1425,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: return # M4 (Parquet Migration): Use unified run_tagging with parallel support try: - from tagging import tagger as _tagger # type: ignore + from tagging import tagger as _tagger use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"} max_workers_env = os.getenv('WEB_TAG_WORKERS') try: @@ -1466,7 +1466,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: try: _write_status({"running": True, "phase": "aggregating", "message": "Consolidating card data...", "percent": 90}) out("Aggregating card CSVs into Parquet files...") - from file_setup.card_aggregator import CardAggregator # type: ignore + from file_setup.card_aggregator import CardAggregator aggregator = CardAggregator() # Aggregate all_cards.parquet @@ -1474,7 +1474,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: out(f"Aggregated {stats['total_cards']} cards into all_cards.parquet ({stats['file_size_mb']} MB)") # Convert commander_cards.csv and background_cards.csv to Parquet - import pandas as pd # type: ignore + import pandas as pd # Convert commander_cards.csv commander_csv = 'csv_files/commander_cards.csv' @@ -1524,8 +1524,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None: # Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress _refresh_theme_catalog(out, force=True, fast_path=False) try: - from .theme_catalog_loader import bust_filter_cache # type: ignore - from .theme_preview import bust_preview_cache # type: ignore + from .theme_catalog_loader import bust_filter_cache + from .theme_preview import bust_preview_cache bust_filter_cache("tagging_complete") bust_preview_cache("tagging_complete") except Exception: @@ -1721,19 +1721,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Owned/Prefer-owned integration (optional for headless runs) try: if use_owned_only: - b.use_owned_only = True # type: ignore[attr-defined] + b.use_owned_only = True # Prefer explicit owned_names list if provided; else let builder discover from files if owned_names: try: - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) except Exception: - b.owned_card_names = set() # type: ignore[attr-defined] + b.owned_card_names = set() # Soft preference flag does not filter; only biases selection order if prefer_owned: try: - b.prefer_owned = True # type: ignore[attr-defined] + b.prefer_owned = True if owned_names and not getattr(b, 'owned_card_names', None): - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) except Exception: pass except Exception: @@ -1751,13 +1751,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Thread combo preferences (if provided) try: if prefer_combos is not None: - b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined] + b.prefer_combos = bool(prefer_combos) if combo_target_count is not None: - b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] + b.combo_target_count = int(combo_target_count) if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal # type: ignore[attr-defined] + b.combo_balance = bal except Exception: pass @@ -1934,7 +1934,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception: pass if hasattr(b, 'export_decklist_csv'): - csv_path = b.export_decklist_csv() # type: ignore[attr-defined] + csv_path = b.export_decklist_csv() except Exception as e: out(f"CSV export failed: {e}") try: @@ -1942,7 +1942,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Try to mirror build_deck_full behavior by displaying the contents import os as _os base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "") - txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] + txt_path = b.export_decklist_text(filename=base + '.txt') try: b._display_txt_contents(txt_path) except Exception: @@ -1950,7 +1950,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + rep0 = b.compute_and_print_compliance(base_stem=base) # Attach planning preview (no mutation) and only auto-enforce if explicitly enabled rep0 = _attach_enforcement_plan(b, rep0) try: @@ -1959,7 +1959,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + b.enforce_and_reexport(base_stem=base, mode='auto') except Exception: pass # Load compliance JSON for UI consumption @@ -1981,7 +1981,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Build structured summary for UI try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() # type: ignore[attr-defined] + summary = b.build_deck_summary() except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -1999,7 +1999,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i "txt": txt_path, } try: - commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] + commander_meta = b.get_commander_export_metadata() except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -2383,21 +2383,21 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) -> """Attach combined commander metadata to the builder.""" try: - builder.combined_commander = combined # type: ignore[attr-defined] + builder.combined_commander = combined except Exception: pass try: - builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined] + builder.partner_mode = getattr(combined, "partner_mode", None) except Exception: pass try: - builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined] + builder.secondary_commander = getattr(combined, "secondary_name", None) except Exception: pass try: - builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined] - builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined] - builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined] + builder.combined_color_identity = getattr(combined, "color_identity", None) + builder.combined_theme_tags = getattr(combined, "theme_tags", None) + builder.partner_warnings = getattr(combined, "warnings", None) except Exception: pass commander_dict = getattr(builder, "commander_dict", None) @@ -2583,17 +2583,17 @@ def start_build_ctx( # Owned-only / prefer-owned (if requested) try: if use_owned_only: - b.use_owned_only = True # type: ignore[attr-defined] + b.use_owned_only = True if owned_names: try: - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) except Exception: - b.owned_card_names = set() # type: ignore[attr-defined] + b.owned_card_names = set() if prefer_owned: try: - b.prefer_owned = True # type: ignore[attr-defined] + b.prefer_owned = True if owned_names and not getattr(b, 'owned_card_names', None): - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) except Exception: pass except Exception: @@ -2646,14 +2646,14 @@ def start_build_ctx( # Thread combo config try: if combo_target_count is not None: - b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] + b.combo_target_count = int(combo_target_count) except Exception: pass try: if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal # type: ignore[attr-defined] + b.combo_balance = bal except Exception: pass # Stages @@ -2735,23 +2735,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: - ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] + ctx["csv_path"] = b.export_decklist_csv() except Exception as e: logs.append(f"CSV export failed: {e}") if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) - ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] + b.export_run_config_json(directory='config', filename=base + '.json') except Exception: pass # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + rep0 = b.compute_and_print_compliance(base_stem=base) rep0 = _attach_enforcement_plan(b, rep0) try: import os as __os @@ -2759,7 +2759,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + b.enforce_and_reexport(base_stem=base, mode='auto') except Exception: pass # Load compliance JSON for UI consumption @@ -2811,7 +2811,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal summary = None try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() # type: ignore[attr-defined] + summary = b.build_deck_summary() except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -2830,7 +2830,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "txt": ctx.get("txt_path"), } try: - commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] + commander_meta = b.get_commander_export_metadata() except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -2890,12 +2890,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp_now = None try: if hasattr(b, 'compute_and_print_compliance'): - comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp_now = b.compute_and_print_compliance(base_stem=None) except Exception: comp_now = None try: if comp_now: - comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined] + comp_now = _attach_enforcement_plan(b, comp_now) except Exception: pass # If still FAIL, return the saved result without advancing or rerunning @@ -3407,7 +3407,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp = b.compute_and_print_compliance(base_stem=None) except Exception: comp = None try: @@ -3508,7 +3508,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp = b.compute_and_print_compliance(base_stem=None) except Exception: comp = None try: @@ -3575,7 +3575,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + comp = b.compute_and_print_compliance(base_stem=None) except Exception: comp = None try: @@ -3617,23 +3617,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): try: - ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] + ctx["csv_path"] = b.export_decklist_csv() except Exception as e: logs.append(f"CSV export failed: {e}") if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) - ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] + b.export_run_config_json(directory='config', filename=base + '.json') except Exception: pass # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + rep0 = b.compute_and_print_compliance(base_stem=base) rep0 = _attach_enforcement_plan(b, rep0) try: import os as __os @@ -3641,7 +3641,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + b.enforce_and_reexport(base_stem=base, mode='auto') except Exception: pass # Load compliance JSON for UI consumption @@ -3662,7 +3662,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal summary = None try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() # type: ignore[attr-defined] + summary = b.build_deck_summary() except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -3681,7 +3681,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "txt": ctx.get("txt_path"), } try: - commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] + commander_meta = b.get_commander_export_metadata() except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] diff --git a/code/web/services/partner_suggestions.py b/code/web/services/partner_suggestions.py index 91eb97e..b781ef5 100644 --- a/code/web/services/partner_suggestions.py +++ b/code/web/services/partner_suggestions.py @@ -362,7 +362,7 @@ def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[Part if allow_auto_refresh: _DATASET_REFRESH_ATTEMPTED = True try: - from .orchestrator import _maybe_refresh_partner_synergy # type: ignore + from .orchestrator import _maybe_refresh_partner_synergy _maybe_refresh_partner_synergy(None, force=True) except Exception as refresh_exc: # pragma: no cover - best-effort diff --git a/code/web/services/preview_cache.py b/code/web/services/preview_cache.py index 2f2b368..b93a688 100644 --- a/code/web/services/preview_cache.py +++ b/code/web/services/preview_cache.py @@ -21,7 +21,7 @@ import json import threading import math -from .preview_metrics import record_eviction # type: ignore +from .preview_metrics import record_eviction # Phase 2 extraction: adaptive TTL band policy moved into preview_policy from .preview_policy import ( @@ -30,7 +30,7 @@ from .preview_policy import ( DEFAULT_TTL_MIN as _POLICY_TTL_MIN, DEFAULT_TTL_MAX as _POLICY_TTL_MAX, ) -from .preview_cache_backend import redis_store # type: ignore +from .preview_cache_backend import redis_store TTL_SECONDS = 600 # Backward-compat variable names retained (tests may reference) mapping to policy constants diff --git a/code/web/services/preview_cache_backend.py b/code/web/services/preview_cache_backend.py index 3750d22..d24d635 100644 --- a/code/web/services/preview_cache_backend.py +++ b/code/web/services/preview_cache_backend.py @@ -24,9 +24,9 @@ import os import time try: # lazy optional dependency - import redis # type: ignore + import redis except Exception: # pragma: no cover - absence path - redis = None # type: ignore + redis = None _URL = os.getenv("THEME_PREVIEW_REDIS_URL") _DISABLED = (os.getenv("THEME_PREVIEW_REDIS_DISABLE") or "").lower() in {"1","true","yes","on"} @@ -42,7 +42,7 @@ def _init() -> None: _INIT_ERR = "disabled_or_missing" return try: - _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore + _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # lightweight ping (non-fatal) try: _CLIENT.ping() @@ -86,7 +86,7 @@ def redis_get(key: Tuple[str, int, str | None, str | None, str]) -> Optional[Dic return None try: skey = "tpv:" + "|".join([str(part) for part in key]) - raw: bytes | None = _CLIENT.get(skey) # type: ignore + raw: bytes | None = _CLIENT.get(skey) if not raw: return None obj = json.loads(raw.decode("utf-8")) diff --git a/code/web/services/sampling.py b/code/web/services/sampling.py index f7e9aad..40d8a0b 100644 --- a/code/web/services/sampling.py +++ b/code/web/services/sampling.py @@ -130,7 +130,7 @@ def sample_real_cards_for_theme(theme: str, limit: int, colors_filter: Optional[ if allow_splash: off = ci - commander_colors if len(off) == 1: - c["_splash_off_color"] = True # type: ignore + c["_splash_off_color"] = True new_pool.append(c) continue pool = new_pool diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py index aee1a3f..4bb10eb 100644 --- a/code/web/services/summary_utils.py +++ b/code/web/services/summary_utils.py @@ -7,7 +7,7 @@ from .combo_utils import detect_for_summary as _detect_for_summary def _owned_set_helper() -> set[str]: try: - from .build_utils import owned_set as _owned_set # type: ignore + from .build_utils import owned_set as _owned_set return _owned_set() except Exception: @@ -21,7 +21,7 @@ def _owned_set_helper() -> set[str]: def _sanitize_tag_list(values: Iterable[Any]) -> List[str]: cleaned: List[str] = [] - for raw in values or []: # type: ignore[arg-type] + for raw in values or []: text = str(raw or "").strip() if not text: continue @@ -78,7 +78,7 @@ def format_theme_label(raw: Any) -> str: def format_theme_list(values: Iterable[Any]) -> List[str]: seen: set[str] = set() result: List[str] = [] - for raw in values or []: # type: ignore[arg-type] + for raw in values or []: label = format_theme_label(raw) if not label: continue diff --git a/code/web/services/theme_catalog_loader.py b/code/web/services/theme_catalog_loader.py index 9212b78..e7c6247 100644 --- a/code/web/services/theme_catalog_loader.py +++ b/code/web/services/theme_catalog_loader.py @@ -26,10 +26,10 @@ from pydantic import BaseModel # - Docker (WORKDIR /app/code): modules also available top-level. # - Package/zip installs (rare): may require 'code.' prefix. try: - from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore + from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry except ImportError: # pragma: no cover - fallback path try: - from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore + from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry except ImportError: # pragma: no cover - last resort (avoid beyond top-level relative import) raise @@ -97,7 +97,7 @@ def _needs_reload() -> bool: if not CATALOG_JSON.exists(): return bool(_CACHE) mtime = CATALOG_JSON.stat().st_mtime - idx: SlugThemeIndex | None = _CACHE.get("index") # type: ignore + idx: SlugThemeIndex | None = _CACHE.get("index") if idx is None: return True if mtime > idx.mtime: @@ -121,7 +121,7 @@ def _needs_reload() -> bool: # Fast path: use os.scandir for lower overhead vs Path.glob newest = 0.0 try: - with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type] + with _os.scandir(YAML_DIR) as it: for entry in it: if entry.is_file() and entry.name.endswith('.yml'): try: @@ -164,7 +164,7 @@ def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str: def load_index() -> SlugThemeIndex: if not _needs_reload(): - return _CACHE["index"] # type: ignore + return _CACHE["index"] if not CATALOG_JSON.exists(): raise FileNotFoundError("theme_list.json missing") raw = json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") @@ -220,7 +220,7 @@ def validate_catalog_integrity(rebuild: bool = True) -> Dict[str, Any]: out.update({"ok": False, "error": f"read_error:{e}"}) return out # Recompute hash using same heuristic as build script - from scripts.build_theme_catalog import load_catalog_yaml # type: ignore + from scripts.build_theme_catalog import load_catalog_yaml try: yaml_catalog = load_catalog_yaml(verbose=False) # keyed by display_name except Exception: @@ -495,7 +495,7 @@ def prewarm_common_filters(max_archetypes: int = 12) -> None: # Gather archetypes & buckets (limited) archetypes: List[str] = [] try: - archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] # type: ignore[arg-type] + archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] except Exception: archetypes = [] buckets = ["Very Common", "Common", "Uncommon", "Niche", "Rare"] diff --git a/code/web/services/theme_preview.py b/code/web/services/theme_preview.py index d1d3991..cc406af 100644 --- a/code/web/services/theme_preview.py +++ b/code/web/services/theme_preview.py @@ -17,7 +17,7 @@ import json try: import yaml # type: ignore except Exception: # pragma: no cover - PyYAML already in requirements; defensive - yaml = None # type: ignore + yaml = None from .preview_metrics import ( record_build_duration, record_role_counts, @@ -51,8 +51,8 @@ from .preview_cache import ( store_cache_entry, evict_if_needed, ) -from .preview_cache_backend import redis_get # type: ignore -from .preview_metrics import record_redis_get, record_redis_store # type: ignore +from .preview_cache_backend import redis_get +from .preview_metrics import record_redis_get, record_redis_store # Local alias to maintain existing internal variable name usage _PREVIEW_CACHE = PREVIEW_CACHE @@ -66,7 +66,7 @@ __all__ = ["get_theme_preview", "preview_metrics", "bust_preview_cache"] ## (duplicate imports removed) # Legacy constant alias retained for any external references; now a function in cache module. -TTL_SECONDS = ttl_seconds # type: ignore +TTL_SECONDS = ttl_seconds # Per-theme error histogram (P2 observability) _PREVIEW_PER_THEME_ERRORS: Dict[str, int] = {} @@ -89,7 +89,7 @@ def _load_curated_synergy_matrix() -> None: # Expect top-level key 'pairs' but allow raw mapping pairs = data.get('pairs', data) if isinstance(pairs, dict): - _CURATED_SYNERGY_MATRIX = pairs # type: ignore + _CURATED_SYNERGY_MATRIX = pairs else: _CURATED_SYNERGY_MATRIX = None else: diff --git a/code/web/static/css_backup_pre_tailwind/styles.css b/code/web/static/css_backup_pre_tailwind/styles.css new file mode 100644 index 0000000..eda7352 --- /dev/null +++ b/code/web/static/css_backup_pre_tailwind/styles.css @@ -0,0 +1,1208 @@ +/* Base */ +:root{ + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; /* focus ring */ + --ok: #16a34a; /* success */ + --warn: #f59e0b; /* warning */ + --err: #ef4444; /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); +} + +/* Light blend between Slate and Parchment (leans gray) */ +[data-theme="light-blend"]{ + --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; /* slightly warm muted */ + --border: #d6d1c7; /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; +} + +[data-theme="dark"]{ + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; +} +[data-theme="high-contrast"]{ + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; +} +[data-theme="cb-friendly"]{ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ +} +*{box-sizing:border-box} +html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} +body { + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; +} +/* Honor HTML hidden attribute across the app */ +[hidden] { display: none !important; } +/* Accessible focus ring for keyboard navigation */ +.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* Top banner */ +.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } +.top-banner{ min-height: var(--banner-h); } +.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } +.top-banner .top-inner > div{ min-width:0; } +@media (max-width: 1100px){ + .top-banner .top-inner{ grid-auto-rows:auto; } + .top-banner .top-inner select{ max-width:140px; } +} +.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } +.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } +.banner-status.busy{ color:#fbbf24; } +.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } +.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } + +/* Layout */ +.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} +.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } + +/* Collapsible sidebar behavior */ +body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } +body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } +body.nav-collapsed .content{ grid-column: 2; } +body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } +body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } +/* Smooth hide/show on mobile while keeping fixed positioning */ +.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } +/* Suppress sidebar transitions during page load to prevent pop-in */ +body.no-transition .sidebar{ transition: none !important; } +/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ +body.htmx-settling .sidebar{ transition: none !important; } +body.htmx-settling .layout{ transition: none !important; } +body.htmx-settling .content{ transition: none !important; } +body.htmx-settling *{ transition-duration: 0s !important; } + +/* Mobile tweaks */ +@media (max-width: 900px){ + :root{ --sidebar-w: 240px; } + .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } + .banner-status{ padding-left: .5rem; } + .layout{ grid-template-columns: 0 1fr; } + .sidebar{ transform: translateX(-100%); visibility: hidden; } + body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } + body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } + .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } + .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } + /* Spacing tweaks: tighter left, larger gaps between visible items */ + .top-banner .top-inner > div{ gap: 25px !important; } + .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } + /* Mobile: show only Menu, Title, and Theme selector */ + #btn-open-permalink{ display:none !important; } + #banner-status{ display:none !important; } + #health-dot{ display:none !important; } + .top-banner #theme-reset{ display:none !important; } +} + +/* Additional mobile spacing for bottom floating controls */ +@media (max-width: 720px) { + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } +} + +.brand h1{ display:none; } +.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } +.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } +.dot.green{ background: var(--green-main); } +.dot.blue{ background: var(--blue-main); } +.dot.red{ background: var(--red-main); } +.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } +.dot.black{ background: var(--black-light); } + +.nav{ display:flex; flex-direction:column; gap:.35rem; } +.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } +.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } + +/* Sidebar theme controls anchored at bottom */ +.sidebar .nav { flex: 1 1 auto; } +.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } +.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } +.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } +.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } + +/* Simple two-column layout for inspect panel */ +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } +.two-col .grow { min-width: 0; } +.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } +@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +/* Left-rail variant puts the image first */ +.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } +/* Ensure left-rail variant also collapses to 1 column on small screens */ +@media (max-width: 900px){ + .two-col.two-col-left-rail{ grid-template-columns: 1fr; } + /* So the commander image doesn't dominate on mobile */ + .two-col .card-preview{ max-width: 360px; margin: 0 auto; } + .two-col .card-preview img{ width: 100%; height: auto; } +} +.card-preview.card-sm{ max-width:200px; } + +/* Buttons, inputs */ +button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } +button:hover{ filter:brightness(1.05); } +/* Anchor-style buttons */ +.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } +.btn:hover{ filter:brightness(1.05); text-decoration:none; } +.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } +label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } +select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } +fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } +small, .muted{ color: var(--muted); } +.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } +.partner-preview[hidden]{ display:none !important; } +.partner-preview__header{ font-weight:600; } +.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } +.partner-preview__art{ flex:0 0 auto; } +.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } +.partner-preview__details{ flex:1 1 180px; min-width:0; } +.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } +.partner-preview__pairing{ margin-top:.35rem; } +.partner-preview__themes{ margin-top:.35rem; font-size:12px; } +.partner-preview--static{ margin-bottom:.5rem; } +.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +/* Toasts */ +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } + +/* Skeletons */ +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } +[data-skeleton][data-skeleton-overlay="false"]::after, +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton]::after{ + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; +} +[data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } +@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } + +/* Banner */ +.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } +.banner h1{ font-size: 2rem; margin:0 0 .35rem; } +.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +/* Home actions */ +.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } +.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } +.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } +.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +/* Card grid for added cards (responsive, compact tiles) */ +.card-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; +} +@media (max-width: 420px){ + .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-tile{ width: 100%; } + .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } +} +.card-tile{ + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; +} +.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } +.card-tile.locked{ + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; +} +.card-tile.must-include{ + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); +} +.card-tile.must-exclude{ + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; +} +.card-tile.must-include.must-exclude{ + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} +.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } +.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } +.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } + +.must-have-controls{ + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; +} +.must-have-btn{ + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; +} +.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); +} +.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); +} +.must-have-btn:focus-visible{ + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; +} +.card-tile.must-exclude .must-have-btn.include[data-active="0"], +.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ + opacity:.65; +} + +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } + +/* Shared ownership badge for card tiles and stacked images */ +.owned-badge{ + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; +} + +/* Step 1 candidate grid (200px-wide scaled images) */ +.candidate-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; +} +.candidate-tile{ + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} +.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } +.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } +.candidate-tile .meta{ text-align:center; margin-top:.35rem; } +.candidate-tile .name{ font-weight:600; font-size:.95rem; } +.candidate-tile .score{ color:var(--muted); font-size:.85rem; } + +/* Deck summary: highlight game changers */ +.game-changer { color: var(--green-main); } +.stack-card.game-changer { outline: 2px solid var(--green-main); } + +/* Image button inside card tiles */ +.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +/* Stage Navigator */ +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } +.stage-nav .name { font-size:12px; } + +/* Build controls sticky box tweaks */ +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); +} + +@media (max-width: 1024px){ + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } +} +@media (min-width: 721px){ + :root { --banner-offset: 48px; } +} + +/* Progress bar */ +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +/* Chips */ +.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } +.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } + +/* Cards toolbar */ +.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } +.cards-toolbar input[type="text"]{ min-width: 220px; } +.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } +.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +/* Collapse groups and reason toggle */ +.group{ margin:.5rem 0; } +.group-header{ display:flex; align-items:center; gap:.5rem; } +.group-header h5{ margin:.4rem 0; } +.group-header .count{ color: var(--muted); font-size:12px; } +.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } +.group-grid[data-collapsed]{ display:none; } +.hide-reasons .card-tile .reason{ display:none; } +.card-tile.force-show .reason{ display:block !important; } +.card-tile.force-hide .reason{ display:none !important; } +.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } +.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } +.chips-inline .chip{ cursor:pointer; user-select:none; } + +/* Inline error banner */ +.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } +.inline-error-banner .muted{ color:#fda4af; } + +/* Alternatives panel */ +.alts ul{ list-style:none; padding:0; margin:0; } +.alts li{ display:flex; align-items:center; gap:.4rem; } +/* LQIP blur/fade-in for thumbnails */ +img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } +img.lqip.loaded { filter: blur(0); opacity: 1; } + +/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; } + img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } +} + +/* Virtualization wrapper should mirror grid to keep multi-column flow */ +.virt-wrapper { display: grid; } + +/* Mobile responsive fixes for horizontal scrolling issues */ +@media (max-width: 768px) { + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } + + /* Test hand responsive adjustments */ + #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + + /* Modal & form layout fixes (original block retained inside media query) */ + /* Fix modal layout on mobile */ + .modal { + padding: 10px !important; + box-sizing: border-box; + } + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + /* Force single column for include/exclude grid */ + .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } + /* Fix basics grid */ + .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } + /* Ensure all inputs and textareas fit properly */ + .modal input, + .modal textarea, + .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } + /* Fix chips containers */ + .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } + /* Ensure fieldsets don't overflow */ + .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } + /* Fix any inline styles that might cause overflow */ + .modal fieldset > div, + .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } +} + +@media (max-width: 640px){ + #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } + /* Generic stack shrink */ + .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } +} + +@media (max-width: 560px){ + #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } + #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } + #test-hand .stack-card{ flex:0 0 auto; } + .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } +} + +@media (max-width: 480px) { + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; /* Much smaller padding */ + font-size: 12px !important; /* Smaller font */ + min-height: 36px !important; /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + .build-controls .sep, + .build-controls .replace-toggle, + .build-controls label[style*="margin-left"] { + display: none !important; + } + + .build-controls .sep { + display: none !important; /* Hide separators on mobile */ + } +} + +/* Desktop sizing for Test Hand */ +@media (min-width: 900px) { + #test-hand { --card-w: 280px !important; --card-h: 392px !important; } +} + +/* Analytics accordion styling */ +.analytics-accordion { + transition: all 0.2s ease; +} + +.analytics-accordion summary { + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.analytics-accordion summary:hover { + background: #1f2937; + border-color: #374151; +} + +.analytics-accordion summary:active { + transform: scale(0.99); +} + +.analytics-accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.analytics-accordion .analytics-content { + animation: accordion-slide-down 0.3s ease-out; +} + +@keyframes accordion-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-placeholder .skeleton-pulse { + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Ideals Slider Styling */ +.ideals-slider { + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; +} + +.ideals-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.ideals-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.slider-value { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Card Details button on tiles */ +.card-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; +} + +.card-details-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); +} + +.card-details-btn svg { + flex-shrink: 0; +} + +/* Card Preview Modal */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.preview-modal.active { + display: flex; +} + +.preview-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.preview-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.preview-close { + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.preview-close:hover { + background: #fff; + transform: scale(1.1); +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} + +/* Card Detail Page Styles */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.card-tag { + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; +} + +.back-button:hover { + background: var(--ring); + color: white; + border-color: var(--ring); +} + +/* Card Detail Page - Main Card Image */ +.card-image-large { + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; +} + +.card-image-large img { + width: 100%; + height: auto; + border-radius: 12px; +} diff --git a/code/web/static/app.js b/code/web/static/js_backup_pre_typescript/app.js similarity index 100% rename from code/web/static/app.js rename to code/web/static/js_backup_pre_typescript/app.js diff --git a/code/web/static/js_backup_pre_typescript/components.js b/code/web/static/js_backup_pre_typescript/components.js new file mode 100644 index 0000000..de4021c --- /dev/null +++ b/code/web/static/js_backup_pre_typescript/components.js @@ -0,0 +1,375 @@ +/** + * M2 Component Library - JavaScript Utilities + * + * Core functions for interactive components: + * - Card flip button (dual-faced cards) + * - Collapsible panels + * - Card popups + * - Modal management + */ + +// ============================================ +// CARD FLIP FUNCTIONALITY +// ============================================ + +/** + * Flip a dual-faced card image between front and back faces + * @param {HTMLElement} button - The flip button element + */ +function flipCard(button) { + const container = button.closest('.card-thumb-container, .card-popup-image'); + if (!container) return; + + const img = container.querySelector('img'); + if (!img) return; + + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length < 2) return; + + // Determine current face (default to 0 = front) + const currentFace = parseInt(img.dataset.currentFace || '0', 10); + const nextFace = currentFace === 0 ? 1 : 0; + const faceName = faces[nextFace]; + + // Determine image version based on container + const isLarge = container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image'); + const version = isLarge ? 'normal' : 'small'; + + // Update image source + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; + img.alt = `${faceName} image`; + img.dataset.currentFace = nextFace.toString(); + + // Update button aria-label + const otherFace = faces[currentFace]; + button.setAttribute('aria-label', `Flip to ${otherFace}`); +} + +/** + * Reset all card images to show front face + * Useful when navigating between pages or clearing selections + */ +function resetCardFaces() { + document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { + const cardName = img.dataset.cardName; + const faces = cardName.split(' // '); + if (faces.length > 1) { + const frontFace = faces[0]; + const container = img.closest('.card-thumb-container, .card-popup-image'); + const isLarge = container && (container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image')); + const version = isLarge ? 'normal' : 'small'; + + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; + img.alt = `${frontFace} image`; + img.dataset.currentFace = '0'; + } + }); +} + +// ============================================ +// COLLAPSIBLE PANEL FUNCTIONALITY +// ============================================ + +/** + * Toggle a collapsible panel's expanded/collapsed state + * @param {string} panelId - The ID of the panel element + */ +function togglePanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + + // Toggle state + button.setAttribute('aria-expanded', (!isExpanded).toString()); + content.style.display = isExpanded ? 'none' : 'block'; + + // Toggle classes + panel.classList.toggle('panel-expanded', !isExpanded); + panel.classList.toggle('panel-collapsed', isExpanded); +} + +/** + * Expand a collapsible panel + * @param {string} panelId - The ID of the panel element + */ +function expandPanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'true'); + content.style.display = 'block'; + panel.classList.add('panel-expanded'); + panel.classList.remove('panel-collapsed'); +} + +/** + * Collapse a collapsible panel + * @param {string} panelId - The ID of the panel element + */ +function collapsePanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'false'); + content.style.display = 'none'; + panel.classList.add('panel-collapsed'); + panel.classList.remove('panel-expanded'); +} + +// ============================================ +// MODAL MANAGEMENT +// ============================================ + +/** + * Open a modal by ID + * @param {string} modalId - The ID of the modal element + */ +function openModal(modalId) { + const modal = document.getElementById(modalId); + if (!modal) return; + + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // Focus first focusable element in modal + const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusable) { + setTimeout(() => focusable.focus(), 100); + } +} + +/** + * Close a modal by ID or element + * @param {string|HTMLElement} modalOrId - Modal element or ID + */ +function closeModal(modalOrId) { + const modal = typeof modalOrId === 'string' + ? document.getElementById(modalOrId) + : modalOrId; + + if (!modal) return; + + modal.remove(); + + // Restore body scroll if no other modals are open + if (!document.querySelector('.modal')) { + document.body.style.overflow = ''; + } +} + +/** + * Close all open modals + */ +function closeAllModals() { + document.querySelectorAll('.modal').forEach(modal => modal.remove()); + document.body.style.overflow = ''; +} + +// ============================================ +// CARD POPUP FUNCTIONALITY +// ============================================ + +/** + * Show card details popup on hover or tap + * @param {string} cardName - The card name + * @param {Object} options - Popup options + * @param {string[]} options.tags - Card tags + * @param {string[]} options.highlightTags - Tags to highlight + * @param {string} options.role - Card role + * @param {string} options.layout - Card layout (for flip button) + */ +function showCardPopup(cardName, options = {}) { + // Remove any existing popup + closeCardPopup(); + + const { + tags = [], + highlightTags = [], + role = '', + layout = 'normal' + } = options; + + const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); + const baseName = cardName.split(' // ')[0]; + + // Create popup HTML + const popup = document.createElement('div'); + popup.className = 'card-popup'; + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-label', `${cardName} details`); + + let tagsHTML = ''; + if (tags.length > 0) { + tagsHTML = '
'; + 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 new file mode 100644 index 0000000..986f565 --- /dev/null +++ b/code/web/static/shared-components.css @@ -0,0 +1,655 @@ +/* Shared Component Styles - Not processed by Tailwind PurgeCSS */ + +/* Card-style list items (used in theme catalog, commander browser, etc.) */ +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +/* Filter chips (used in theme catalog, card browser, etc.) */ +.filter-chip { + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 2px 8px; + border-radius: 14px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; +} + +.filter-chip-remove { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 0; + line-height: 1; +} + +/* Loading skeleton cards (used in theme catalog, deck lists, etc.) */ +.skeleton-card { + height: 48px; + border-radius: 8px; + background: linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%); + background-size: 200% 100%; + animation: sk 1.2s ease-in-out infinite; +} + +/* Search suggestion dropdowns (used in theme catalog, card search, etc.) */ +.search-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--panel); + border: 1px solid var(--border); + border-top: none; + z-index: 25; + display: none; + max-height: 300px; + overflow: auto; + border-radius: 0 0 8px 8px; +} + +.search-suggestions a { + display: block; + padding: 0.5rem 0.6rem; + font-size: 13px; + text-decoration: none; + color: var(--text); + border-bottom: 1px solid var(--border); + transition: background 0.15s ease; +} + +.search-suggestions a:last-child { + border-bottom: none; +} + +.search-suggestions a:hover, +.search-suggestions a.selected { + background: var(--hover); +} + +.search-suggestions a.selected { + border-left: 3px solid var(--ring); + padding-left: calc(0.6rem - 3px); +} + +/* Card reference links (clickable card names with hover preview) */ +.card-ref { + cursor: pointer; + text-decoration: underline dotted; +} + +.card-ref:hover { + color: var(--accent); +} + +/* Modal components (used in new deck modal, settings modals, etc.) */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + overflow: auto; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); +} + +.modal-content { + position: relative; + max-width: 720px; + width: clamp(320px, 90vw, 720px); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-height: min(92vh, 100%); + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Form field components */ +.form-label { + display: block; + margin-bottom: 0.5rem; +} + +.form-checkbox-label { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 0.5rem; + margin: 0; + width: 100%; + cursor: pointer; + text-align: left; +} + +.form-checkbox-label input[type="checkbox"], +.form-checkbox-label input[type="radio"] { + margin: 0; + cursor: pointer; +} + +/* Include/Exclude card chips (green/red themed) */ +.include-chips-container { + margin-top: 0.5rem; + min-height: 30px; + border: 1px solid #4ade80; + border-radius: 6px; + padding: 0.5rem; + background: rgba(74, 222, 128, 0.05); + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: flex-start; +} + +.exclude-chips-container { + margin-top: 0.5rem; + min-height: 30px; + border: 1px solid #ef4444; + border-radius: 6px; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.05); + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: flex-start; +} + +.chips-inner { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + flex: 1; +} + +.chips-placeholder { + color: #6b7280; + font-size: 11px; + font-style: italic; +} + +/* Card list textarea styling */ +.include-textarea { + width: 100%; + min-height: 60px; + resize: vertical; + font-family: monospace; + font-size: 12px; + border-left: 3px solid #4ade80; + border-right: 1px solid var(--border); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + color: var(--text); + background: var(--bg); +} + +.include-textarea::placeholder { + color: var(--muted); + opacity: 0.7; +} + +/* Alternative card buttons - force text wrapping */ +.alt-option { + display: block !important; + width: 100% !important; + max-width: 100% !important; + text-align: left !important; + white-space: normal !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + line-height: 1.3 !important; + padding: 0.5rem 0.7rem !important; +} + +.exclude-textarea { + width: 100%; + min-height: 60px; + resize: vertical; + font-family: monospace; + font-size: 12px; + border-left: 3px solid #ef4444; + border-right: 1px solid var(--border); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + color: var(--text); + background: var(--bg); +} + +.exclude-textarea::placeholder { + color: var(--muted); + opacity: 0.7; +} + +/* Info/warning panels */ +.info-panel { + margin-top: 0.75rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 6px; +} + +.info-panel summary { + cursor: pointer; + font-size: 12px; + color: #60a5fa; +} + +.info-panel-content { + margin-top: 0.5rem; + font-size: 12px; + line-height: 1.5; +} + +/* Include/Exclude card list helpers */ +.include-exclude-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 0.5rem; +} + +@media (max-width: 768px) { + .include-exclude-grid { + grid-template-columns: 1fr; + } +} + +.card-list-label { + display: block; + margin-bottom: 0.5rem; +} + +.card-list-label small { + color: #9ca3af; + opacity: 1; +} + +.card-list-label-include { + color: #4ade80; + font-weight: 500; +} + +.card-list-label-exclude { + color: #ef4444; + font-weight: 500; +} + +.card-list-controls { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 12px; +} + +.card-list-count { + font-size: 11px; +} + +.card-list-validation { + margin-top: 0.5rem; + font-size: 12px; +} + +.card-list-badges { + display: flex; + gap: 0.25rem; + font-size: 10px; +} + +/* Button variants for include/exclude controls */ +.btn-upload-include { + cursor: pointer; + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #065f46; + border-color: #059669; +} + +.btn-upload-exclude { + cursor: pointer; + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #7f1d1d; + border-color: #dc2626; +} + +.btn-clear { + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #7f1d1d; + border-color: #dc2626; +} + +/* Modal footer */ +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: space-between; + margin-top: 1rem; +} + +.modal-footer-left { + display: flex; + gap: 0.5rem; +} + +/* Chip dot color variants */ +.dot-green { + background: var(--green-main); +} + +.dot-blue { + background: var(--blue-main); +} + +.dot-orange { + background: var(--orange-main, #f97316); +} + +.dot-red { + background: var(--red-main); +} + +.dot-purple { + background: var(--purple-main, #a855f7); +} + +/* Form label with icon */ +.form-label-icon { + display: flex; + align-items: center; + gap: 0.35rem; +} + +/* Inline form (for control buttons) */ +.inline-form { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* Locked cards list */ +.locked-list { + list-style: none; + padding: 0; + margin: 0.35rem 0 0; + display: grid; + gap: 0.35rem; +} + +.locked-item { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.lock-box-inline { + display: inline; + margin-left: auto; +} + +/* Build controls sticky section */ +.build-controls { + position: sticky; + z-index: 5; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.5rem; + margin-top: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +/* Alert box */ +.alert-error { + margin-top: 0.5rem; + color: #fecaca; + background: #7f1d1d; + border: 1px solid #991b1b; + padding: 0.5rem 0.75rem; + border-radius: 8px; +} + +/* Stage timeline list */ +.timeline-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.25rem; +} + +.timeline-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Card action buttons container */ +.card-actions-center { + display: flex; + justify-content: center; + margin-top: 0.25rem; + gap: 0.35rem; + flex-wrap: wrap; +} + +/* Ownership badge (small circular indicator) */ +.ownership-badge { + display: inline-block; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + border-radius: 12px; + font-size: 12px; + line-height: 18px; + height: 18px; + min-width: 18px; + padding: 0 6px; + text-align: center; +} + +/* Build log pre formatting */ +.build-log { + margin-top: 0.5rem; + white-space: pre-wrap; + background: var(--panel); + border: 1px solid var(--border); + padding: 1rem; + border-radius: 8px; + max-height: 40vh; + overflow: auto; +} + +/* Last action status area (prevents layout shift) */ +.last-action { + min-height: 1.5rem; +} + +/* Deck summary section divider */ +.summary-divider { + margin: 1.25rem 0; + border-color: var(--border); +} + +/* Summary type heading */ +.summary-type-heading { + margin: 0.5rem 0 0.25rem 0; + font-weight: 600; +} + +/* Summary view controls */ +.summary-view-controls { + margin: 0.5rem 0 0.25rem 0; + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Summary section spacing */ +.summary-section { + margin-top: 0.5rem; +} + +.summary-section-lg { + margin-top: 1rem; +} + +/* Land breakdown note chips */ +.land-note-chip-expand { + background: #0f172a; + border-color: #34d399; + color: #a7f3d0; +} + +.land-note-chip-counts { + background: #111827; + border-color: #60a5fa; + color: #bfdbfe; +} + +/* Land breakdown list */ +.land-breakdown-list { + list-style: none; + padding: 0; + margin: 0.35rem 0 0; + display: grid; + gap: 0.35rem; +} + +.land-breakdown-item { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: flex-start; +} + +.land-breakdown-subs { + list-style: none; + padding: 0; + margin: 0.2rem 0 0; + display: grid; + gap: 0.15rem; + flex: 1 0 100%; +} + +.land-breakdown-sub { + font-size: 0.85rem; + color: #e5e7eb; + opacity: 0.85; +} + +/* Deck metrics wrap */ +.deck-metrics-wrap { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-start; +} + +/* Combo summary styling */ +.combo-summary { + cursor: pointer; + user-select: none; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + font-weight: 600; + transition: background-color 0.15s ease; +} + +.combo-summary:hover { + background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); + border-color: var(--text); +} + +/* Mana analytics row grid */ +.mana-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + align-items: stretch; +} + +/* Mana panel container */ +.mana-panel { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem; + background: var(--panel); +} + +/* Mana panel heading */ +.mana-panel-heading { + margin-bottom: 0.35rem; + font-weight: 600; +} + +/* Chart bars container */ +.chart-bars { + display: flex; + gap: 14px; + align-items: flex-end; + height: 140px; +} + +/* Chart column center-aligned text */ +.chart-column { + text-align: center; +} + +/* Chart SVG cursor */ +.chart-svg { + cursor: pointer; +} + +/* Existing card tile styles (for reference/consolidation) */ +.card-tile { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.card-tile:hover { + background: var(--hover); +} + +/* Theme detail card styles (for reference/consolidation) */ +.theme-detail-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} diff --git a/code/web/static/styles.css b/code/web/static/styles.css index eda7352..d0593a6 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -1,738 +1,2848 @@ +/* Tailwind CSS Entry Point */ + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* ! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com */ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.\!container { + width: 100% !important; +} + +.container { + width: 100%; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.visible { + visibility: visible; +} + +.collapse { + visibility: collapse; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.m-0 { + margin: 0px; +} + +.-my-1\.5 { + margin-top: -0.375rem; + margin-bottom: -0.375rem; +} + +.my-1 { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.my-1\.5 { + margin-top: 0.375rem; + margin-bottom: 0.375rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-3\.5 { + margin-top: 0.875rem; + margin-bottom: 0.875rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-1\.5 { + margin-bottom: 0.375rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-3\.5 { + margin-bottom: 0.875rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-6 { + margin-left: 1.5rem; +} + +.ml-auto { + margin-left: auto; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-0\.5 { + margin-top: 0.125rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-1\.5 { + margin-top: 0.375rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.\!block { + display: block !important; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.\!grid { + display: grid !important; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-12 { + height: 3rem; +} + +.h-auto { + height: auto; +} + +.min-h-\[1\.1em\] { + min-height: 1.1em; +} + +.min-h-\[1rem\] { + min-height: 1rem; +} + +.w-24 { + width: 6rem; +} + +.w-full { + width: 100%; +} + +.min-w-\[160px\] { + min-width: 160px; +} + +.min-w-\[2\.5rem\] { + min-width: 2.5rem; +} + +.min-w-\[220px\] { + min-width: 220px; +} + +.max-w-\[230px\] { + max-width: 230px; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-shrink { + flex-shrink: 1; +} + +.grow { + flex-grow: 1; +} + +.border-collapse { + border-collapse: collapse; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-pointer { + cursor: pointer; +} + +.select-all { + -webkit-user-select: all; + -moz-user-select: all; + user-select: all; +} + +.resize { + resize: both; +} + +.list-none { + list-style-type: none; +} + +.grid-cols-\[2fr_1fr\] { + grid-template-columns: 2fr 1fr; +} + +.grid-cols-\[repeat\(auto-fill\2c minmax\(230px\2c 1fr\)\)\] { + grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-1\.5 { + gap: 0.375rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-2\.5 { + gap: 0.625rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-3\.5 { + gap: 0.875rem; +} + +.gap-4 { + gap: 1rem; +} + +.overflow-hidden { + overflow: hidden; +} + +.text-ellipsis { + text-overflow: ellipsis; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded-\[10px\] { + border-radius: 10px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-0 { + border-width: 0px; +} + +.border-\[var\(--border\)\] { + border-color: var(--border); +} + +.bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); +} + +.p-0 { + padding: 0px; +} + +.p-2 { + padding: 0.5rem; +} + +.px-1\.5 { + padding-left: 0.375rem; + padding-right: 0.375rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-\[11px\] { + font-size: 11px; +} + +.text-\[13px\] { + font-size: 13px; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-medium { + font-weight: 500; +} + +.font-normal { + font-weight: 400; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.capitalize { + text-transform: capitalize; +} + +.italic { + font-style: italic; +} + +.text-\[var\(--text\)\] { + color: var(--text); +} + +.text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity, 1)); +} + +.underline { + text-decoration-line: underline; +} + +.no-underline { + text-decoration-line: none; +} + +.opacity-30 { + opacity: 0.3; +} + +.opacity-70 { + opacity: 0.7; +} + +.opacity-85 { + opacity: 0.85; +} + +.outline { + outline-style: solid; +} + +.ring { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +.\[start\:end\] { + start: end; +} + +/* Import custom CSS (not purged by Tailwind) */ + /* Base */ + :root{ - /* MTG color palette (approx from provided values) */ - --banner-h: 52px; - --sidebar-w: 260px; - --green-main: rgb(0,115,62); - --green-light: rgb(196,211,202); - --blue-main: rgb(14,104,171); - --blue-light: rgb(179,206,234); - --red-main: rgb(211,32,42); - --red-light: rgb(235,159,130); - --white-main: rgb(249,250,244); - --white-light: rgb(248,231,185); - --black-main: rgb(21,11,0); - --black-light: rgb(166,159,157); - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; - --ring: #60a5fa; /* focus ring */ - --ok: #16a34a; /* success */ - --warn: #f59e0b; /* warning */ - --err: #ef4444; /* error */ - /* Surface overrides for specific regions (default to panel) */ - --surface-banner: var(--panel); - --surface-banner-text: var(--text); - --surface-sidebar: var(--panel); - --surface-sidebar-text: var(--text); + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; + /* focus ring */ + --ok: #16a34a; + /* success */ + --warn: #f59e0b; + /* warning */ + --err: #ef4444; + /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); } /* Light blend between Slate and Parchment (leans gray) */ + [data-theme="light-blend"]{ - --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ - --panel: #ffffff; /* crisp panels for readability */ - --text: #0b0d12; - --muted: #6b655d; /* slightly warm muted */ - --border: #d6d1c7; /* neutral warm-gray border */ - /* Slightly darker banner/sidebar for separation */ - --surface-banner: #1a1b1e; - --surface-sidebar: #1a1b1e; - --surface-banner-text: #e8e8e8; - --surface-sidebar-text: #e8e8e8; + --bg: #e8e2d0; + /* warm beige background (keep existing) */ + --panel: #ebe5d8; + /* lighter warm cream - more contrast with bg, subtle panels */ + --text: #0d0a08; + /* very dark brown/near-black for strong readability */ + --muted: #5a544c; + /* darker muted brown for better contrast */ + --border: #bfb5a3; + /* darker warm-gray border for better definition */ + /* Navbar/banner: darker warm brown for hierarchy */ + --surface-banner: #9b8f7a; + /* warm medium brown - darker than panels, lighter than dark theme */ + --surface-sidebar: #9b8f7a; + /* match banner for consistency */ + --surface-banner-text: #1a1410; + /* dark brown text on medium brown bg */ + --surface-sidebar-text: #1a1410; + /* dark brown text on medium brown bg */ + /* Button colors: use taupe for buttons so they stand out from light panels */ + --btn-bg: #d4cbb8; + /* medium warm taupe - stands out against light panels */ + --btn-text: #1a1410; + /* dark brown text */ + --btn-hover-bg: #c4b9a5; + /* darker taupe on hover */ } [data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; } + [data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; } + [data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; /* darker green */ - --red-main: #c62828; /* deeper red */ - --blue-main: #1565c0; /* balanced blue */ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; + /* darker green */ + --red-main: #c62828; + /* deeper red */ + --blue-main: #1565c0; + /* balanced blue */ } -*{box-sizing:border-box} -html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} + +*{ + box-sizing:border-box +} + +html{ + height:100%; + overflow-x:hidden; + overflow-y:scroll; + max-width:100vw; +} + body { - font-family: system-ui, Arial, sans-serif; - margin: 0; - color: var(--text); - background: var(--bg); - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: auto; + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: scroll; } + /* Honor HTML hidden attribute across the app */ -[hidden] { display: none !important; } -/* Accessible focus ring for keyboard navigation */ -.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -/* Top banner */ -.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } -.top-banner{ min-height: var(--banner-h); } -.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } -.top-banner .top-inner > div{ min-width:0; } -@media (max-width: 1100px){ - .top-banner .top-inner{ grid-auto-rows:auto; } - .top-banner .top-inner select{ max-width:140px; } + +[hidden] { + display: none !important; +} + +/* Accessible focus ring for keyboard navigation */ + +.focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +/* Top banner - simplified, no changes on sidebar toggle */ + +.top-banner{ + position:sticky; + top:0; + z-index:10; + background: var(--surface-banner); + color: var(--surface-banner-text); + border-bottom:1px solid var(--border); + box-shadow:0 2px 6px rgba(0,0,0,.4); + min-height: var(--banner-h); +} + +.top-banner .top-inner{ + margin:0; + padding:.4rem 15px; + display:flex; + align-items:center; + width:100%; + box-sizing:border-box; +} + +.top-banner h1{ + font-size: 1.1rem; + margin:0; + margin-left: 25px; +} + +.flex-row{ + display: flex; + align-items: center; + gap: 25px; +} + +.top-banner .banner-left{ + width: 260px !important; + flex-shrink: 0 !important; +} + +/* Hide elements on all screen sizes */ + +#btn-open-permalink{ + display:none !important; +} + +#banner-status{ + display:none !important; +} + +.top-banner #theme-reset{ + display:none !important; } -.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } -.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } -.banner-status.busy{ color:#fbbf24; } -.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } -.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } /* Layout */ -.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } -.sidebar{ - background: var(--surface-sidebar); - color: var(--surface-sidebar-text); - border-right: 1px solid var(--border); - padding: 1rem; - position: fixed; - top: var(--banner-h); - left: 0; - bottom: 0; - overflow: auto; - width: var(--sidebar-w); - z-index: 9; /* below the banner (z=10) */ - box-shadow: 2px 0 10px rgba(0,0,0,.18); - display: flex; - flex-direction: column; + +.layout{ + display:grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr); + flex: 1 0 auto; +} + +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; + /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} + +.content{ + padding: 1.25rem 1.5rem; + grid-column: 2; + min-width: 0; } -.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } /* Collapsible sidebar behavior */ -body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } -body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } -body.nav-collapsed .content{ grid-column: 2; } -body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } -body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } + +body.nav-collapsed .layout{ + grid-template-columns: 0 minmax(0, 1fr); +} + +body.nav-collapsed .sidebar{ + transform: translateX(-100%); + visibility: hidden; +} + +body.nav-collapsed .content{ + grid-column: 2; +} + +/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ + /* Smooth hide/show on mobile while keeping fixed positioning */ -.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } + +.sidebar{ + transition: transform .2s ease-out, visibility .2s linear; + overflow-x: hidden; +} + /* Suppress sidebar transitions during page load to prevent pop-in */ -body.no-transition .sidebar{ transition: none !important; } + +body.no-transition .sidebar{ + transition: none !important; +} + /* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ -body.htmx-settling .sidebar{ transition: none !important; } -body.htmx-settling .layout{ transition: none !important; } -body.htmx-settling .content{ transition: none !important; } -body.htmx-settling *{ transition-duration: 0s !important; } + +body.htmx-settling .sidebar{ + transition: none !important; +} + +body.htmx-settling .layout{ + transition: none !important; +} + +body.htmx-settling .content{ + transition: none !important; +} + +body.htmx-settling *{ + transition-duration: 0s !important; +} /* Mobile tweaks */ + @media (max-width: 900px){ - :root{ --sidebar-w: 240px; } - .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } - .banner-status{ padding-left: .5rem; } - .layout{ grid-template-columns: 0 1fr; } - .sidebar{ transform: translateX(-100%); visibility: hidden; } - body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } - body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } - .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } - .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } - /* Spacing tweaks: tighter left, larger gaps between visible items */ - .top-banner .top-inner > div{ gap: 25px !important; } - .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } - /* Mobile: show only Menu, Title, and Theme selector */ - #btn-open-permalink{ display:none !important; } - #banner-status{ display:none !important; } - #health-dot{ display:none !important; } - .top-banner #theme-reset{ display:none !important; } + :root{ + --sidebar-w: 240px; + } + + .layout{ + grid-template-columns: 0 1fr; + } + + .sidebar{ + transform: translateX(-100%); + visibility: hidden; + } + + body:not(.nav-collapsed) .layout{ + grid-template-columns: var(--sidebar-w) 1fr; + } + + body:not(.nav-collapsed) .sidebar{ + transform: translateX(0); + visibility: visible; + } + + .content{ + padding: .9rem .6rem; + max-width: 100vw; + box-sizing: border-box; + overflow-x: hidden; + } } /* Additional mobile spacing for bottom floating controls */ + @media (max-width: 720px) { - .content { - padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ - } + .content { + padding-bottom: 6rem !important; + /* Extra bottom padding to account for floating controls */ + } } -.brand h1{ display:none; } -.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } -.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } -.dot.green{ background: var(--green-main); } -.dot.blue{ background: var(--blue-main); } -.dot.red{ background: var(--red-main); } -.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } -.dot.black{ background: var(--black-light); } +.brand h1{ + display:none; +} -.nav{ display:flex; flex-direction:column; gap:.35rem; } -.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } -.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } +.brand{ + padding-top: 0; + margin-top: 0; +} + +.mana-dots{ + display:flex; + gap:.35rem; + margin-bottom:.5rem; + margin-top: 0; + padding-top: 0; +} + +.mana-dots .dot{ + width:12px; + height:12px; + border-radius:50%; + display:inline-block; + border:1px solid rgba(0,0,0,.35); + box-shadow:0 1px 2px rgba(0,0,0,.3) inset; +} + +.dot.green{ + background: var(--green-main); +} + +.dot.blue{ + background: var(--blue-main); +} + +.dot.red{ + background: var(--red-main); +} + +.dot.white{ + background: var(--white-light); + border-color: rgba(0,0,0,.2); +} + +.dot.black{ + background: var(--black-light); +} + +.nav{ + display:flex; + flex-direction:column; + gap:.35rem; +} + +.nav a{ + color: var(--surface-sidebar-text); + text-decoration:none; + padding:.4rem .5rem; + border-radius:6px; + border:1px solid transparent; +} + +.nav a:hover{ + background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); + border-color: var(--border); +} /* Sidebar theme controls anchored at bottom */ -.sidebar .nav { flex: 1 1 auto; } -.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } -.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } -.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } -.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } -.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } + +.sidebar .nav { + flex: 1 1 auto; +} + +.sidebar-theme { + margin-top: auto; + padding-top: .75rem; + border-top: 1px solid var(--border); +} + +.sidebar-theme-label { + display:block; + color: var(--surface-sidebar-text); + font-size: 12px; + opacity:.8; + margin: 0 0 .35rem .1rem; +} + +.sidebar-theme-row { + display:flex; + align-items:center; + gap:.5rem; + flex-wrap: nowrap; +} + +.sidebar-theme-row select { + background: var(--panel); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.3rem .4rem; + flex: 1 1 auto; + min-width: 0; +} + +.sidebar-theme-row .btn-ghost { + background: transparent; + color: var(--surface-sidebar-text); + border:1px solid var(--border); + flex-shrink: 0; + white-space: nowrap; +} /* Simple two-column layout for inspect panel */ -.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } -.two-col .grow { min-width: 0; } -.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } -@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +.two-col { + display: grid; + grid-template-columns: 1fr 320px; + gap: 1rem; + align-items: start; +} + +.two-col .grow { + min-width: 0; +} + +.card-preview img { + width: 100%; + height: auto; + border-radius: 10px; + box-shadow: 0 6px 18px rgba(0,0,0,.35); + border:1px solid var(--border); + background: var(--panel); +} + +@media (max-width: 900px) { + .two-col { + grid-template-columns: 1fr; + } +} /* Left-rail variant puts the image first */ -.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } -/* Ensure left-rail variant also collapses to 1 column on small screens */ -@media (max-width: 900px){ - .two-col.two-col-left-rail{ grid-template-columns: 1fr; } - /* So the commander image doesn't dominate on mobile */ - .two-col .card-preview{ max-width: 360px; margin: 0 auto; } - .two-col .card-preview img{ width: 100%; height: auto; } + +.two-col.two-col-left-rail{ + grid-template-columns: 320px 1fr; +} + +/* Ensure left-rail variant also collapses to 1 column on small screens */ + +@media (max-width: 900px){ + .two-col.two-col-left-rail{ + grid-template-columns: 1fr; + } + + /* So the commander image doesn't dominate on mobile */ + + .two-col .card-preview{ + max-width: 360px; + margin: 0 auto; + } + + .two-col .card-preview img{ + width: 100%; + height: auto; + } +} + +.card-preview.card-sm{ + max-width:200px; } -.card-preview.card-sm{ max-width:200px; } /* Buttons, inputs */ -button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } -button:hover{ filter:brightness(1.05); } + +button{ + background: var(--blue-main); + color:#fff; + border:none; + border-radius:6px; + padding:.45rem .7rem; + cursor:pointer; +} + +button:hover{ + filter:brightness(1.05); +} + /* Anchor-style buttons */ -.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } -.btn:hover{ filter:brightness(1.05); text-decoration:none; } -.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } -label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } -.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } -.color-identity .mana + .mana{ margin-left:4px; } -.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } -.mana-W{ background:#f9fafb; border-color:#d1d5db; } -.mana-U{ background:#3b82f6; border-color:#1d4ed8; } -.mana-B{ background:#111827; border-color:#1f2937; } -.mana-R{ background:#ef4444; border-color:#b91c1c; } -.mana-G{ background:#10b981; border-color:#047857; } -.mana-C{ background:#d3d3d3; border-color:#9ca3af; } -select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } -fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } -small, .muted{ color: var(--muted); } -.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } -.partner-preview[hidden]{ display:none !important; } -.partner-preview__header{ font-weight:600; } -.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } -.partner-preview__art{ flex:0 0 auto; } -.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } -.partner-preview__details{ flex:1 1 180px; min-width:0; } -.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } -.partner-preview__pairing{ margin-top:.35rem; } -.partner-preview__themes{ margin-top:.35rem; font-size:12px; } -.partner-preview--static{ margin-bottom:.5rem; } -.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +.btn{ + display:inline-block; + background: var(--blue-main); + color:#fff; + border:none; + border-radius:6px; + padding:.45rem .7rem; + cursor:pointer; + text-decoration:none; + line-height:1; +} + +.btn:hover{ + filter:brightness(1.05); + text-decoration:none; +} + +.btn.disabled, .btn[aria-disabled="true"]{ + opacity:.6; + cursor:default; + pointer-events:none; +} + +label{ + display:inline-flex; + flex-direction:column; + gap:.25rem; + margin-right:.75rem; +} + +.color-identity{ + display:inline-flex; + align-items:center; + gap:.35rem; +} + +.color-identity .mana + .mana{ + margin-left:4px; +} + +.mana{ + display:inline-block; + width:16px; + height:16px; + border-radius:50%; + border:1px solid var(--border); + box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; +} + +.mana-W{ + background:#f9fafb; + border-color:#d1d5db; +} + +.mana-U{ + background:#3b82f6; + border-color:#1d4ed8; +} + +.mana-B{ + background:#111827; + border-color:#1f2937; +} + +.mana-R{ + background:#ef4444; + border-color:#b91c1c; +} + +.mana-G{ + background:#10b981; + border-color:#047857; +} + +.mana-C{ + background:#d3d3d3; + border-color:#9ca3af; +} + +select,input[type="text"],input[type="number"]{ + background: var(--panel); + color:var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.35rem .4rem; +} + +/* Range slider styling */ + +input[type="range"]{ + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + height: 8px; + background: var(--bg); + border-radius: 4px; + outline: none; + border: 1px solid var(--border); +} + +input[type="range"]::-webkit-slider-thumb{ + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: var(--blue-main); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--panel); + box-shadow: 0 2px 4px rgba(0,0,0,.2); +} + +input[type="range"]::-moz-range-thumb{ + width: 20px; + height: 20px; + background: var(--blue-main); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--panel); + box-shadow: 0 2px 4px rgba(0,0,0,.2); +} + +fieldset{ + border:1px solid var(--border); + border-radius:8px; + padding:.75rem; + margin:.75rem 0; +} + +small, .muted{ + color: var(--muted); +} + +.partner-preview{ + border:1px solid var(--border); + border-radius:8px; + background: var(--panel); + padding:.75rem; + margin-bottom:.5rem; +} + +.partner-preview[hidden]{ + display:none !important; +} + +.partner-preview__header{ + font-weight:600; +} + +.partner-preview__layout{ + display:flex; + gap:.75rem; + align-items:flex-start; + flex-wrap:wrap; +} + +.partner-preview__art{ + flex:0 0 auto; +} + +.partner-preview__art img{ + width:140px; + max-width:100%; + border-radius:6px; + box-shadow:0 4px 12px rgba(0,0,0,.35); +} + +.partner-preview__details{ + flex:1 1 180px; + min-width:0; +} + +.partner-preview__role{ + margin-top:.2rem; + font-size:12px; + color:var(--muted); + letter-spacing:.04em; + text-transform:uppercase; +} + +.partner-preview__pairing{ + margin-top:.35rem; +} + +.partner-preview__themes{ + margin-top:.35rem; + font-size:12px; +} + +.partner-preview--static{ + margin-bottom:.5rem; +} + +.partner-card-preview img{ + box-shadow:0 4px 12px rgba(0,0,0,.3); +} /* Toasts */ -.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } -.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } -.toast.hide{ opacity:0; transform: translateY(6px); } -.toast.success{ border-color: rgba(22,163,74,.4); } -.toast.error{ border-color: rgba(239,68,68,.45); } -.toast.warn{ border-color: rgba(245,158,11,.45); } + +.toast-host{ + position: fixed; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 9999; +} + +.toast{ + background: var(--panel); + color:var(--text); + border:1px solid var(--border); + border-radius:10px; + padding:.5rem .65rem; + box-shadow: 0 8px 24px rgba(0,0,0,.35); + transition: transform .2s ease, opacity .2s ease; +} + +.toast.hide{ + opacity:0; + transform: translateY(6px); +} + +.toast.success{ + border-color: rgba(22,163,74,.4); +} + +.toast.error{ + border-color: rgba(239,68,68,.45); +} + +.toast.warn{ + border-color: rgba(245,158,11,.45); +} /* Skeletons */ -[data-skeleton]{ position: relative; } -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } -[data-skeleton-placeholder]{ display:none; pointer-events:none; } -[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } + +[data-skeleton]{ + position: relative; +} + +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ + opacity: 0; +} + +[data-skeleton-placeholder]{ + display:none; + pointer-events:none; +} + +[data-skeleton].is-loading > [data-skeleton-placeholder]{ + display:flex; + flex-direction:column; + opacity:1; +} + [data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton][data-skeleton-overlay="false"]::before{ + display:none !important; +} + [data-skeleton]::after{ - content: ''; - position: absolute; inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; + content: ''; + position: absolute; + inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; } -[data-skeleton].is-loading::after{ display:block; } + +[data-skeleton].is-loading::after{ + display:block; +} + [data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} + +[data-skeleton][data-skeleton-label=""]::before{ + content:''; +} + +@keyframes shimmer{ + 0%{ + background-position: 200% 0; + } + + 100%{ + background-position: -200% 0; + } } -[data-skeleton][data-skeleton-label=""]::before{ content:''; } -@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } /* Banner */ -.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } -.banner h1{ font-size: 2rem; margin:0 0 .35rem; } -.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +.banner{ + background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2rem 1.6rem; + margin-bottom: 1rem; + box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; +} + +.banner h1{ + font-size: 2rem; + margin:0 0 .35rem; +} + +.banner .subtitle{ + color: var(--muted); + font-size:.95rem; +} /* Home actions */ -.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } -.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } -.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } -.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +.actions-grid{ + display:grid; + grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); + gap: .75rem; +} + +.action-button{ + display:block; + text-decoration:none; + color: var(--text); + border:1px solid var(--border); + background: var(--panel); + padding:1.25rem; + border-radius:10px; + text-align:center; + font-weight:600; +} + +.action-button:hover{ + border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.action-button.primary{ + background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); + border-color: #274766; +} + +/* Home page darker buttons */ + +.home-button.btn-secondary { + background: var(--btn-bg, #1a1d24); + color: var(--btn-text, #e8e8e8); + border-color: var(--border); +} + +.home-button.btn-secondary:hover { + background: var(--btn-hover-bg, #22252d); + border-color: var(--border); +} + +.home-button.btn-primary { + background: var(--blue-main); + color: white; + border-color: var(--blue-main); +} + +.home-button.btn-primary:hover { + background: #0c5aa6; + border-color: #0c5aa6; +} /* Card grid for added cards (responsive, compact tiles) */ + .card-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ - gap: .5rem; - margin-top:.5rem; - justify-content: start; /* pack as many as possible per row */ - /* Prevent scroll chaining bounce that can cause flicker near bottom */ - overscroll-behavior: contain; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 640px 420px; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); + /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; + /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; } + @media (max-width: 420px){ - .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .card-tile{ width: 100%; } - .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } + .card-grid{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .card-tile{ + width: 100%; + } + + .card-tile img{ + width: 100%; + max-width: 160px; + margin: 0 auto; + } } + .card-tile{ - width:170px; - position: relative; - background: var(--panel); - border:1px solid var(--border); - border-radius:6px; - padding:.25rem .25rem .4rem; - text-align:center; + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; } -.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } + +.card-tile.game-changer{ + border-color: var(--red-main); + box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; +} + .card-tile.locked{ - /* Subtle yellow/goldish-white accent for locked cards */ - border-color: #f5e6a8; /* soft parchment gold */ - box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; + /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; } + .card-tile.must-include{ - border-color: rgba(74,222,128,.85); - box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); } + .card-tile.must-exclude{ - border-color: rgba(239,68,68,.85); - box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; - opacity: .95; + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; } + .card-tile.must-include.must-exclude{ - border-color: rgba(249,115,22,.85); - box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} + +.card-tile img{ + width:160px; + height:auto; + border-radius:6px; + box-shadow: 0 6px 18px rgba(0,0,0,.35); + background:#111; +} + +.card-tile .name{ + font-weight:600; + margin-top:.25rem; + font-size:.92rem; +} + +.card-tile .reason{ + color:var(--muted); + font-size:.85rem; + margin-top:.15rem; } -.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } -.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } -.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } .must-have-controls{ - display:flex; - justify-content:center; - gap:.35rem; - flex-wrap:wrap; - margin-top:.35rem; + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; } + .must-have-btn{ - border:1px solid var(--border); - background:rgba(30,41,59,.6); - color:#f8fafc; - font-size:11px; - text-transform:uppercase; - letter-spacing:.06em; - padding:.25rem .6rem; - border-radius:9999px; - cursor:pointer; - transition: all .18s ease; + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; } + .must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ - border-color: rgba(74,222,128,.75); - background: rgba(74,222,128,.18); - color: #bbf7d0; - box-shadow: 0 0 0 1px rgba(16,185,129,.25); + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); } + .must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ - border-color: rgba(239,68,68,.75); - background: rgba(239,68,68,.18); - color: #fecaca; - box-shadow: 0 0 0 1px rgba(239,68,68,.25); + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); } + .must-have-btn:focus-visible{ - outline:2px solid rgba(59,130,246,.6); - outline-offset:2px; + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; } + .card-tile.must-exclude .must-have-btn.include[data-active="0"], .card-tile.must-include .must-have-btn.exclude[data-active="0"]{ - opacity:.65; + opacity:.65; } -.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } -.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } +.group-grid{ + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 540px 360px; +} + +.alt-list{ + list-style:none; + padding:0; + margin:0; + display:grid; + gap:.25rem; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 320px 220px; +} + +.alt-option{ + display:block !important; + width:100%; + max-width:100%; + text-align:left; + white-space:normal !important; + word-wrap:break-word !important; + overflow-wrap:break-word !important; + line-height:1.3 !important; + padding:0.5rem 0.7rem !important; +} /* Shared ownership badge for card tiles and stacked images */ + .owned-badge{ - position:absolute; - top:6px; - left:6px; - background:rgba(17,24,39,.9); - color:#e5e7eb; - border:1px solid var(--border); - border-radius:12px; - font-size:12px; - line-height:18px; - height:18px; - min-width:18px; - padding:0 6px; - text-align:center; - pointer-events:none; - z-index:2; + position:absolute; + top:6px; + left:6px; + background:var(--panel); + color:var(--text); + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; } /* Step 1 candidate grid (200px-wide scaled images) */ + .candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; } + .candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} + +.candidate-tile .img-btn{ + display:block; + width:100%; + padding:0; + background:transparent; + border:none; + cursor:pointer; +} + +.candidate-tile img{ + width:100%; + max-width:200px; + height:auto; + border-radius:8px; + box-shadow:0 6px 18px rgba(0,0,0,.35); + background: var(--panel); + display:block; + margin:0 auto; +} + +.candidate-tile .meta{ + text-align:center; + margin-top:.35rem; +} + +.candidate-tile .name{ + font-weight:600; + font-size:.95rem; +} + +.candidate-tile .score{ + color:var(--muted); + font-size:.85rem; } -.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } -.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } -.candidate-tile .meta{ text-align:center; margin-top:.35rem; } -.candidate-tile .name{ font-weight:600; font-size:.95rem; } -.candidate-tile .score{ color:var(--muted); font-size:.85rem; } /* Deck summary: highlight game changers */ -.game-changer { color: var(--green-main); } -.stack-card.game-changer { outline: 2px solid var(--green-main); } + +.game-changer { + color: var(--green-main); +} + +.stack-card.game-changer { + outline: 2px solid var(--green-main); +} /* Image button inside card tiles */ -.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +.card-tile .img-btn{ + display:block; + padding:0; + background:transparent; + border:none; + cursor:pointer; + width:100%; +} /* Stage Navigator */ -.stage-nav { margin:.5rem 0 1rem; } -.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } -.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } -.stage-nav .stage-item.done .stage-link { opacity:.75; } -.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } -.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } -.stage-nav .name { font-size:12px; } + +.stage-nav { + margin:.5rem 0 1rem; +} + +.stage-nav ol { + list-style:none; + padding:0; + margin:0; + display:flex; + gap:.35rem; + flex-wrap:wrap; +} + +.stage-nav .stage-link { + display:flex; + align-items:center; + gap:.4rem; + background: var(--panel); + border:1px solid var(--border); + color:var(--text); + border-radius:999px; + padding:.25rem .6rem; + cursor:pointer; +} + +.stage-nav .stage-item.done .stage-link { + opacity:.75; +} + +.stage-nav .stage-item.current .stage-link { + box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; + border-color:#3b82f6; +} + +.stage-nav .idx { + display:inline-grid; + place-items:center; + width:20px; + height:20px; + border-radius:50%; + background:var(--bg); + font-size:12px; +} + +.stage-nav .name { + font-size:12px; +} /* Build controls sticky box tweaks */ -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 10px; - margin: 0.5rem 0; - box-shadow: 0 4px 12px rgba(0,0,0,.25); + +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: var(--panel); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); } @media (max-width: 1024px){ - :root { --banner-offset: 56px; } - .build-controls { - position: fixed !important; /* Fixed to viewport instead of sticky */ - bottom: 0 !important; /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; /* Override top positioning */ - border-radius: 0 !important; /* Remove border radius for full width */ - margin: 0 !important; /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; /* Higher z-index to ensure it's above content */ - } + :root { + --banner-offset: 56px; + } + + .build-controls { + position: fixed !important; + /* Fixed to viewport instead of sticky */ + bottom: 0 !important; + /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; + /* Override top positioning */ + border-radius: 0 !important; + /* Remove border radius for full width */ + margin: 0 !important; + /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; + /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; + /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; + /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; + /* Higher z-index to ensure it's above content */ + } } + @media (min-width: 721px){ - :root { --banner-offset: 48px; } + :root { + --banner-offset: 48px; + } } /* Progress bar */ -.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } -.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } -.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +.progress { + position: relative; + height: 10px; + background: var(--panel); + border:1px solid var(--border); + border-radius: 999px; + overflow: hidden; +} + +.progress .bar { + position:absolute; + left:0; + top:0; + bottom:0; + width: 0%; + background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); +} + +.progress.flash { + box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; +} /* Chips */ -.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } -.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } + +.chip { + display:inline-flex; + align-items:center; + gap:.35rem; + background: var(--panel); + border:1px solid var(--border); + color:var(--text); + border-radius:999px; + padding:.2rem .55rem; + font-size:12px; +} + +.chip .dot { + width:8px; + height:8px; + border-radius:50%; + background:#6b7280; +} + +.chip:hover { + background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); + border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); +} + +.chip.active { + background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); + border-color: #3b82f6; + color: #60a5fa; + font-weight: 600; + box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; +} + +.chip.active:hover { + background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); + border-color: #60a5fa; +} /* Cards toolbar */ -.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } -.cards-toolbar input[type="text"]{ min-width: 220px; } -.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } -.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +.cards-toolbar{ + display:flex; + flex-wrap:wrap; + gap:.5rem .75rem; + align-items:center; + margin:.5rem 0 .25rem; +} + +.cards-toolbar input[type="text"]{ + min-width: 220px; +} + +.cards-toolbar .sep{ + width:1px; + height:20px; + background: var(--border); + margin:0 .25rem; +} + +.cards-toolbar .hint{ + color: var(--muted); + font-size:12px; +} /* Collapse groups and reason toggle */ -.group{ margin:.5rem 0; } -.group-header{ display:flex; align-items:center; gap:.5rem; } -.group-header h5{ margin:.4rem 0; } -.group-header .count{ color: var(--muted); font-size:12px; } -.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } -.group-grid[data-collapsed]{ display:none; } -.hide-reasons .card-tile .reason{ display:none; } -.card-tile.force-show .reason{ display:block !important; } -.card-tile.force-hide .reason{ display:none !important; } -.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } -.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } -.chips-inline .chip{ cursor:pointer; user-select:none; } + +.group{ + margin:.5rem 0; +} + +.group-header{ + display:flex; + align-items:center; + gap:.5rem; +} + +.group-header h5{ + margin:.4rem 0; +} + +.group-header .count{ + color: var(--muted); + font-size:12px; +} + +.group-header .toggle{ + margin-left:auto; + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.2rem .5rem; + font-size:12px; + cursor:pointer; +} + +.group-grid[data-collapsed]{ + display:none; +} + +.hide-reasons .card-tile .reason{ + display:none; +} + +.card-tile.force-show .reason{ + display:block !important; +} + +.card-tile.force-hide .reason{ + display:none !important; +} + +.btn-why{ + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.15rem .4rem; + font-size:12px; + cursor:pointer; +} + +.chips-inline{ + display:flex; + gap:.35rem; + flex-wrap:wrap; + align-items:center; +} + +.chips-inline .chip{ + cursor:pointer; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} /* Inline error banner */ -.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } -.inline-error-banner .muted{ color:#fda4af; } + +.inline-error-banner{ + background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); + border:1px solid #b91c1c; + color:#b91c1c; + padding:.5rem .6rem; + border-radius:8px; + margin-bottom:.5rem; +} + +.inline-error-banner .muted{ + color:#fda4af; +} /* Alternatives panel */ -.alts ul{ list-style:none; padding:0; margin:0; } -.alts li{ display:flex; align-items:center; gap:.4rem; } + +.alts ul{ + list-style:none; + padding:0; + margin:0; +} + +.alts li{ + display:flex; + align-items:center; + gap:.4rem; +} + /* LQIP blur/fade-in for thumbnails */ -img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } -img.lqip.loaded { filter: blur(0); opacity: 1; } + +img.lqip { + filter: blur(8px); + opacity: .6; + transition: filter .25s ease-out, opacity .25s ease-out; +} + +img.lqip.loaded { + filter: blur(0); + opacity: 1; +} /* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ + @media (prefers-reduced-motion: reduce) { - * { scroll-behavior: auto !important; } - img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } + * { + scroll-behavior: auto !important; + } + + img.lqip { + transition: none !important; + filter: none !important; + opacity: 1 !important; + } } /* Virtualization wrapper should mirror grid to keep multi-column flow */ -.virt-wrapper { display: grid; } + +.virt-wrapper { + display: grid; +} /* Mobile responsive fixes for horizontal scrolling issues */ + @media (max-width: 768px) { - /* Prevent horizontal overflow */ - html, body { - overflow-x: hidden !important; - width: 100% !important; - max-width: 100vw !important; - } + /* Prevent horizontal overflow */ - /* Test hand responsive adjustments */ - #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } - /* Modal & form layout fixes (original block retained inside media query) */ - /* Fix modal layout on mobile */ - .modal { - padding: 10px !important; - box-sizing: border-box; - } - .modal-content { - width: 100% !important; - max-width: calc(100vw - 20px) !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - /* Force single column for include/exclude grid */ - .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } - /* Fix basics grid */ - .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } - /* Ensure all inputs and textareas fit properly */ - .modal input, + /* Test hand responsive adjustments */ + + #test-hand{ + --card-w: 170px !important; + --card-h: 238px !important; + --overlap: .5 !important; + } + + /* Modal & form layout fixes (original block retained inside media query) */ + + /* Fix modal layout on mobile */ + + .modal { + padding: 10px !important; + box-sizing: border-box; + } + + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + + /* Force single column for include/exclude grid */ + + .include-exclude-grid { + display: flex !important; + flex-direction: column !important; + gap: 1rem !important; + } + + /* Fix basics grid */ + + .basics-grid { + grid-template-columns: 1fr !important; + gap: 1rem !important; + } + + /* Ensure all inputs and textareas fit properly */ + + .modal input, .modal textarea, - .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } - /* Fix chips containers */ - .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } - /* Ensure fieldsets don't overflow */ - .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } - /* Fix any inline styles that might cause overflow */ - .modal fieldset > div, - .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } + .modal select { + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; + min-width: 0 !important; + } + + /* Fix chips containers */ + + .modal [id$="_chips_container"] { + max-width: 100% !important; + overflow-x: hidden !important; + word-wrap: break-word !important; + } + + /* Ensure fieldsets don't overflow */ + + .modal fieldset { + max-width: 100% !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + + /* Fix any inline styles that might cause overflow */ + + .modal fieldset > div, + .modal fieldset > div > div { + max-width: 100% !important; + overflow-x: hidden !important; + } } @media (max-width: 640px){ - #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } - /* Generic stack shrink */ - .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } + #test-hand{ + --card-w: 150px !important; + --card-h: 210px !important; + } + + /* Generic stack shrink */ + + .stack-wrap:not(#test-hand){ + --card-w: 150px; + --card-h: 210px; + } } @media (max-width: 560px){ - #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } - #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } - #test-hand .stack-card{ flex:0 0 auto; } - .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } + #test-hand{ + --card-w: 140px !important; + --card-h: 196px !important; + padding-bottom:.75rem; + } + + #test-hand .stack-grid{ + display:flex !important; + gap:.5rem; + grid-template-columns:none !important; + overflow-x:auto; + padding-bottom:.25rem; + } + + #test-hand .stack-card{ + flex:0 0 auto; + } + + .stack-wrap:not(#test-hand){ + --card-w: 140px; + --card-h: 196px; + } } @media (max-width: 480px) { - .modal-content { - padding: 12px !important; - margin: 5px !important; - } - - .modal fieldset { - padding: 8px !important; - margin: 6px 0 !important; - } - - /* Enhanced mobile build controls */ - .build-controls { - flex-direction: column !important; - gap: 0.25rem !important; /* Reduced gap */ - align-items: stretch !important; - padding: 0.5rem !important; /* Reduced padding */ - } - - /* Two-column grid layout for mobile build controls */ - .build-controls { - display: grid !important; - grid-template-columns: 1fr 1fr !important; /* Two equal columns */ - grid-gap: 0.25rem !important; - align-items: stretch !important; - } - - .build-controls form { - display: contents !important; /* Allow form contents to participate in grid */ - width: auto !important; - } - - .build-controls button { - flex: none !important; - padding: 0.4rem 0.5rem !important; /* Much smaller padding */ - font-size: 12px !important; /* Smaller font */ - min-height: 36px !important; /* Smaller minimum height */ - line-height: 1.2 !important; - width: 100% !important; /* Full width within grid cell */ - box-sizing: border-box !important; - white-space: nowrap !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - /* Hide non-essential elements on mobile to keep it clean */ - .build-controls .sep, + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; + /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; + /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; + /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; + /* Much smaller padding */ + font-size: 12px !important; + /* Smaller font */ + min-height: 36px !important; + /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; + /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + + .build-controls .sep, .build-controls .replace-toggle, .build-controls label[style*="margin-left"] { - display: none !important; - } - - .build-controls .sep { - display: none !important; /* Hide separators on mobile */ - } + display: none !important; + } + + .build-controls .sep { + display: none !important; + /* Hide separators on mobile */ + } } /* Desktop sizing for Test Hand */ + @media (min-width: 900px) { - #test-hand { --card-w: 280px !important; --card-h: 392px !important; } + #test-hand { + --card-w: 280px !important; + --card-h: 392px !important; + } } /* Analytics accordion styling */ + .analytics-accordion { - transition: all 0.2s ease; + transition: all 0.2s ease; } .analytics-accordion summary { - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.15s ease, border-color 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; } .analytics-accordion summary:hover { - background: #1f2937; - border-color: #374151; + background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); + border-color: var(--text); } .analytics-accordion summary:active { - transform: scale(0.99); + transform: scale(0.99); } .analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; } .analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; + animation: accordion-slide-down 0.3s ease-out; } @keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; + animation: shimmer 1.5s infinite; } @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } } /* Ideals Slider Styling */ + .ideals-slider { - -webkit-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; } .ideals-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: var(--ring); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + -webkit-transition: all 0.15s ease; + transition: all 0.15s ease; } .ideals-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } .ideals-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: var(--ring); - border: none; - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + -moz-transition: all 0.15s ease; + transition: all 0.15s ease; } .ideals-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } .slider-value { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 4px; + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; } /* ======================================== @@ -740,469 +2850,2840 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } ======================================== */ /* Card browser container */ + .card-browser-container { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } /* Filter panel */ + .card-browser-filters { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; } .filter-section { - display: flex; - flex-direction: column; - gap: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; } .filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; } .filter-row label { - font-weight: 600; - min-width: 80px; - color: var(--text); - font-size: 0.95rem; + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; } .filter-row select, .filter-row input[type="text"], .filter-row input[type="search"] { - flex: 1; - min-width: 150px; - max-width: 300px; + flex: 1; + min-width: 150px; + max-width: 300px; } /* Search bar styling */ + .card-search-wrapper { - position: relative; - flex: 1; - max-width: 100%; + position: relative; + flex: 1; + max-width: 100%; } .card-search-wrapper input[type="search"] { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 1rem; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; } /* Results count and info bar */ + .card-browser-info { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; } .results-count { - font-size: 0.95rem; - color: var(--muted); + font-size: 0.95rem; + color: var(--muted); } .page-indicator { - font-size: 0.95rem; - color: var(--text); - font-weight: 600; + font-size: 0.95rem; + color: var(--text); + font-weight: 600; } /* Card browser grid */ + .card-browser-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); - gap: 0.5rem; - padding: 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - min-height: 480px; - justify-content: start; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; } /* Individual card tile in browser */ + .card-browser-tile { - break-inside: avoid; - display: flex; - flex-direction: column; - background: var(--card-bg, #1a1d24); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; + -moz-column-break-inside: avoid; + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; } .card-browser-tile:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); } .card-browser-tile-image { - position: relative; - width: 100%; - aspect-ratio: 488/680; - overflow: hidden; - background: #0a0b0e; + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; } .card-browser-tile-image img { - width: 100%; - height: 100%; - object-fit: contain; - transition: transform 0.3s ease; + width: 100%; + height: 100%; + -o-object-fit: contain; + object-fit: contain; + transition: transform 0.3s ease; } .card-browser-tile:hover .card-browser-tile-image img { - transform: scale(1.05); + transform: scale(1.05); } .card-browser-tile-info { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; } .card-browser-tile-name { - font-weight: 600; - font-size: 0.95rem; - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; } .card-browser-tile-type { - font-size: 0.85rem; - color: var(--muted); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; } .card-browser-tile-stats { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; } .card-browser-tile-tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; } .card-browser-tile-tags .tag { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - background: rgba(148, 163, 184, 0.15); - color: var(--muted); - border-radius: 3px; - white-space: nowrap; + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; } /* Card Details button on tiles */ + .card-details-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.5rem 0.75rem; - background: var(--primary); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: all 0.2s; - margin-top: 0.5rem; - border: none; - cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; } .card-details-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); } .card-details-btn svg { - flex-shrink: 0; + flex-shrink: 0; } /* Card Preview Modal */ + .preview-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 9999; - align-items: center; - justify-content: center; + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; } .preview-modal.active { - display: flex; + display: flex; } .preview-content { - position: relative; - max-width: 90%; - max-height: 90%; + position: relative; + max-width: 90%; + max-height: 90%; } .preview-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .preview-close { - position: absolute; - top: -40px; - right: 0; - background: rgba(255, 255, 255, 0.9); - color: #000; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 24px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; } .preview-close:hover { - background: #fff; - transform: scale(1.1); + background: #fff; + transform: scale(1.1); } /* Pagination controls */ + .card-browser-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 1rem; - padding: 1rem 0; - flex-wrap: wrap; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; } .card-browser-pagination .btn { - min-width: 120px; + min-width: 120px; } .card-browser-pagination .page-info { - font-size: 0.95rem; - color: var(--text); - padding: 0 1rem; + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; } /* No results message */ + .no-results { - text-align: center; - padding: 3rem 1rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; } .no-results-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin-bottom: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; } .no-results-message { - color: var(--muted); - margin-bottom: 1rem; - line-height: 1.5; + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; } .no-results-filters { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin-bottom: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; } .no-results-filter-tag { - padding: 0.25rem 0.75rem; - background: rgba(148, 163, 184, 0.15); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - color: var(--text); + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); } /* Loading indicator */ + .card-browser-loading { - text-align: center; - padding: 2rem; - color: var(--muted); + text-align: center; + padding: 2rem; + color: var(--muted); } /* Responsive adjustments */ + /* Large tablets and below - reduce to ~180px cards */ + @media (max-width: 1024px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); - } + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } } /* Tablets - reduce to ~160px cards */ + @media (max-width: 768px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); - gap: 0.5rem; - padding: 0.5rem; - } - - .filter-row { - flex-direction: column; - align-items: stretch; - } - - .filter-row label { - min-width: auto; - } - - .filter-row select, + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, .filter-row input { - max-width: 100%; - } - - .card-browser-info { - flex-direction: column; - align-items: flex-start; - } + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } } /* Small tablets/large phones - reduce to ~140px cards */ + @media (max-width: 600px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); - gap: 0.5rem; - } + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } } /* Phones - 2 column layout with flexible width */ + @media (max-width: 480px) { - .card-browser-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; - } - - .card-browser-tile-name { - font-size: 0.85rem; - } - - .card-browser-tile-type { - font-size: 0.75rem; - } - - .card-browser-tile-info { - padding: 0.5rem; - } + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } } /* Theme chips for multi-select */ + .theme-chip { - display: inline-flex; - align-items: center; - background: var(--primary-bg); - color: var(--primary-fg); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-size: 0.9rem; - border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); } .theme-chip button { - margin-left: 0.5rem; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - font-weight: bold; - font-size: 1.2rem; - line-height: 1; + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; } .theme-chip button:hover { - color: var(--error-color); + color: var(--error-color); } /* Card Detail Page Styles */ + .card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; - margin-bottom: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; } .card-tag { - background: var(--ring); - color: white; - padding: 0.35rem 0.75rem; - border-radius: 16px; - font-size: 0.85rem; - font-weight: 500; + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; } .back-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: var(--panel); - color: var(--text); - text-decoration: none; - border-radius: 8px; - border: 1px solid var(--border); - font-weight: 500; - transition: all 0.2s; - margin-bottom: 2rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; } .back-button:hover { - background: var(--ring); - color: white; - border-color: var(--ring); + background: var(--ring); + color: white; + border-color: var(--ring); } /* Card Detail Page - Main Card Image */ + .card-image-large { - flex: 0 0 auto; - max-width: 360px !important; - width: 100%; + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; } .card-image-large img { - width: 100%; - height: auto; - border-radius: 12px; + width: 100%; + height: auto; + border-radius: 12px; } + +/* ============================================ + M2 Component Library Styles + ============================================ */ + +/* === BUTTONS === */ + +/* Button Base - enhanced from existing .btn */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--blue-main); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + text-decoration: none; + line-height: 1.5; + font-weight: 500; + transition: filter 0.15s ease, transform 0.05s ease; + white-space: nowrap; +} + +.btn:hover { + filter: brightness(1.1); + text-decoration: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn.disabled, +.btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Button Variants */ + +.btn-primary { + background: var(--blue-main); + color: #fff; +} + +.btn-secondary { + background: var(--muted); + color: var(--text); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + background: var(--panel); + border-color: var(--text); +} + +.btn-danger { + background: var(--err); + color: #fff; +} + +/* Button Sizes */ + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + +.btn-md { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Icon Button */ + +.btn-icon { + padding: 0.5rem; + aspect-ratio: 1; + justify-content: center; +} + +.btn-icon.btn-sm { + padding: 0.25rem; + font-size: 1rem; +} + +/* Close Button */ + +.btn-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.5rem; + line-height: 1; + z-index: 10; +} + +/* Tag/Chip Button */ + +.btn-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.btn-tag:hover { + background: var(--border); + border-color: var(--text); +} + +.btn-tag-selected { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.btn-tag-remove { + background: transparent; + border: none; + color: inherit; + padding: 0; + margin: 0; + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.btn-tag-remove:hover { + opacity: 1; +} + +/* Button Group */ + +.btn-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-group-left { + justify-content: flex-start; +} + +.btn-group-center { + justify-content: center; +} + +.btn-group-right { + justify-content: flex-end; +} + +.btn-group-between { + justify-content: space-between; +} + +/* Legacy action-btn compatibility */ + +.action-btn { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* === MODALS === */ + +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + z-index: -1; +} + +.modal-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + width: 100%; + max-height: min(92vh, 100%); + display: flex; + flex-direction: column; +} + +/* Modal Sizes */ + +.modal-sm .modal-content { + max-width: 480px; +} + +.modal-md .modal-content { + max-width: 620px; +} + +.modal-lg .modal-content { + max-width: 720px; +} + +.modal-xl .modal-content { + max-width: 960px; +} + +/* Modal Position */ + +.modal-center { + align-items: center; +} + +.modal-top { + align-items: flex-start; + padding-top: 2rem; +} + +/* Modal Scrollable */ + +.modal-scrollable .modal-content { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Modal Structure */ + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-right: 2rem; +} + +.modal-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +/* Modal Variants */ + +.modal-confirm .modal-body { + padding: 1rem 0; + font-size: 0.95rem; +} + +.modal-alert { + text-align: center; +} + +.modal-alert .modal-body { + padding: 1.5rem 0; +} + +.modal-alert .alert-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.modal-alert-info .alert-icon::before { + content: 'ℹ️'; +} + +.modal-alert-success .alert-icon::before { + content: '✅'; +} + +.modal-alert-warning .alert-icon::before { + content: '⚠️'; +} + +.modal-alert-error .alert-icon::before { + content: '❌'; +} + +/* === FORMS === */ + +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.form-label { + font-weight: 500; + font-size: 0.875rem; + color: var(--text); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.form-required { + color: var(--err); + font-weight: bold; +} + +.form-input-wrapper { + display: flex; + flex-direction: column; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-input:disabled, +.form-textarea:disabled, +.form-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-input-number { + max-width: 150px; +} + +.form-input-file { + padding: 0.375rem 0.5rem; +} + +/* Checkbox and Radio */ + +.form-field-checkbox, +.form-field-radio { + flex-direction: row; + align-items: flex-start; +} + +.form-checkbox-label, +.form-radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: normal; +} + +.form-checkbox, +.form-radio { + width: 1.125rem; + height: 1.125rem; + border: 1px solid var(--border); + cursor: pointer; + flex-shrink: 0; +} + +.form-checkbox { + border-radius: 4px; +} + +.form-radio { + border-radius: 50%; +} + +.form-checkbox:checked, +.form-radio:checked { + background: var(--blue-main); + border-color: var(--blue-main); +} + +.form-checkbox:focus, +.form-radio:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Form Help and Error Text */ + +.form-help-text { + font-size: 0.8rem; + color: var(--muted); + margin-top: -0.25rem; +} + +.form-error-text { + font-size: 0.8rem; + color: var(--err); + margin-top: -0.25rem; +} + +.form-field-error .form-input, +.form-field-error .form-textarea, +.form-field-error .form-select { + border-color: var(--err); +} + +/* === CARD DISPLAY COMPONENTS === */ + +/* Card Thumbnail Container */ + +.card-thumb-container { + position: relative; + display: inline-block; +} + +.card-thumb { + display: block; + border-radius: 10px; + border: 1px solid var(--border); + background: #0b0d12; + -o-object-fit: cover; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-thumb:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); +} + +/* Card Thumbnail Sizes */ + +.card-thumb-small .card-thumb { + width: 160px; + height: auto; +} + +.card-thumb-medium .card-thumb { + width: 230px; + height: auto; +} + +.card-thumb-large .card-thumb { + width: 360px; + height: auto; +} + +/* Card Flip Button */ + +.card-flip-btn { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background 0.15s ease; + z-index: 5; +} + +.card-flip-btn:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.4); +} + +.card-flip-btn svg { + width: 16px; + height: 16px; +} + +/* Card Name Label */ + +.card-name-label { + font-size: 0.75rem; + margin-top: 0.375rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + text-align: center; +} + +/* Card Hover Popup */ + +.card-popup { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.card-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); + z-index: -1; +} + +.card-popup-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-width: 400px; + width: 100%; +} + +.card-popup-image { + position: relative; + margin-bottom: 1rem; +} + +.card-popup-image img { + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--border); +} + +.card-popup-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-popup-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.card-popup-role { + font-size: 0.875rem; + color: var(--muted); +} + +.card-popup-role span { + color: var(--text); + font-weight: 500; +} + +.card-popup-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.card-popup-tag { + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; +} + +.card-popup-tag-highlight { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.card-popup-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: none; + border-radius: 6px; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.card-popup-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +/* Card Grid */ + +.card-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.card-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.card-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +.card-grid-cols-5 { + grid-template-columns: repeat(5, 1fr); +} + +.card-grid-cols-6 { + grid-template-columns: repeat(6, 1fr); +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* Card List */ + +.card-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + transition: background 0.15s ease; +} + +.card-list-item:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.card-list-item-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.card-list-item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-list-item-count { + color: var(--muted); + font-size: 0.875rem; +} + +.card-list-item-role { + color: var(--muted); + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +/* Synthetic Card Placeholder */ + +.card-sample.synthetic { + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--panel); + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.synthetic-card-placeholder { + text-align: center; +} + +.synthetic-card-icon { + font-size: 2rem; + opacity: 0.5; + margin-bottom: 0.5rem; +} + +.synthetic-card-name { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.synthetic-card-reason { + font-size: 0.75rem; + color: var(--muted); +} + +/* === PANELS === */ + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.75rem; +} + +/* Panel Variants */ + +.panel-default { + background: var(--panel); +} + +.panel-alt { + background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); +} + +.panel-dark { + background: #0f1115; +} + +.panel-bordered { + background: transparent; +} + +/* Panel Padding */ + +.panel-padding-none { + padding: 0; +} + +.panel-padding-sm { + padding: 0.5rem; +} + +.panel-padding-md { + padding: 0.75rem; +} + +.panel-padding-lg { + padding: 1.5rem; +} + +/* Panel Structure */ + +.panel-header { + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.panel-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.panel-body { + padding: 0.75rem; +} + +.panel-footer { + padding: 0.75rem; + border-top: 1px solid var(--border); +} + +/* Info Panel */ + +.panel-info { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.panel-info-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; +} + +.panel-info-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.panel-info-text { + flex: 1; +} + +.panel-info-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem; + color: var(--text); +} + +.panel-info-message { + font-size: 0.875rem; + color: var(--muted); +} + +.panel-info-action { + flex-shrink: 0; +} + +/* Info Panel Variants */ + +.panel-info-info { + border-color: var(--ring); + background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); +} + +.panel-info-success { + border-color: var(--ok); + background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); +} + +.panel-info-warning { + border-color: var(--warn); + background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); +} + +.panel-info-error { + border-color: var(--err); + background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); +} + +/* Stat Panel */ + +.panel-stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + flex-direction: column; +} + +.panel-stat-icon { + font-size: 2rem; +} + +.panel-stat-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.panel-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: var(--text); +} + +.panel-stat-label { + font-size: 0.875rem; + color: var(--muted); + margin-top: 0.25rem; +} + +.panel-stat-sublabel { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.125rem; +} + +/* Stat Panel Variants */ + +.panel-stat-primary { + border-color: var(--ring); +} + +.panel-stat-primary .panel-stat-value { + color: var(--ring); +} + +.panel-stat-success { + border-color: var(--ok); +} + +.panel-stat-success .panel-stat-value { + color: var(--ok); +} + +.panel-stat-warning { + border-color: var(--warn); +} + +.panel-stat-warning .panel-stat-value { + color: var(--warn); +} + +.panel-stat-error { + border-color: var(--err); +} + +.panel-stat-error .panel-stat-value { + color: var(--err); +} + +/* Collapsible Panel */ + +.panel-collapsible .panel-header { + padding: 0; + border: none; +} + +.panel-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + text-align: left; + border-radius: 10px 10px 0 0; + transition: background 0.15s ease; +} + +.panel-toggle:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.panel-toggle-icon { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid var(--text); + transition: transform 0.2s ease; +} + +.panel-collapsed .panel-toggle-icon { + transform: rotate(-90deg); +} + +.panel-expanded .panel-toggle-icon { + transform: rotate(0deg); +} + +.panel-collapse-content { + overflow: hidden; + transition: max-height 0.3s ease; +} + +/* Panel Grid */ + +.panel-grid { + display: grid; + gap: 1rem; +} + +.panel-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.panel-grid-cols-1 { + grid-template-columns: 1fr; +} + +.panel-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.panel-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.panel-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 768px) { + .panel-grid { + grid-template-columns: 1fr; + } +} + +/* Empty State Panel */ + +.panel-empty-state { + text-align: center; + padding: 3rem 1.5rem; +} + +.panel-empty-icon { + font-size: 4rem; + opacity: 0.5; + margin-bottom: 1rem; +} + +.panel-empty-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--text); +} + +.panel-empty-message { + font-size: 0.95rem; + color: var(--muted); + margin: 0 0 1.5rem; +} + +.panel-empty-action { + display: flex; + justify-content: center; +} + +/* Loading Panel */ + +.panel-loading { + text-align: center; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.panel-loading-spinner { + width: 3rem; + height: 3rem; + border: 4px solid var(--border); + border-top-color: var(--ring); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.panel-loading-message { + font-size: 0.95rem; + color: var(--muted); +} + +/* ============================================================================= + UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) + ============================================================================= */ + +/* Flex Row Layouts */ + +.flex-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.flex-row-sm { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.flex-row-md { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.flex-row-lg { + display: flex; + align-items: center; + gap: 1rem; +} + +.flex-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.flex-row-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.flex-row-start { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* Flex Column Layouts */ + +.flex-col { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.flex-col-sm { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.flex-col-md { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flex-col-lg { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.flex-col-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +/* Flex Grid/Wrap Patterns */ + +.flex-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.flex-grid-sm { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.flex-grid-md { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.flex-grid-lg { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +/* Spacing Utilities */ + +.section-spacing { + margin-top: 2rem; +} + +.section-spacing-sm { + margin-top: 1rem; +} + +.section-spacing-lg { + margin-top: 3rem; +} + +.content-spacing { + margin-bottom: 1rem; +} + +.content-spacing-sm { + margin-bottom: 0.5rem; +} + +.content-spacing-lg { + margin-bottom: 2rem; +} + +/* Common Size Constraints */ + +.max-w-content { + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.max-w-prose { + max-width: 65ch; + margin-left: auto; + margin-right: auto; +} + +.max-w-form { + max-width: 600px; +} + +/* Common Text Patterns */ + +.text-muted { + color: var(--muted); + opacity: 0.85; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1.25; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.35; +} + +.text-base { + font-size: 1rem; + line-height: 1.5; +} + +/* Screen Reader Only */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================================================= + CARD HOVER SYSTEM (Moved from base.html 2025-10-21) + ============================================================================= */ + +.card-hover { + position: fixed; + pointer-events: none; + z-index: 9999; + display: none; +} + +.card-hover-inner { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-hover img { + width: 320px; + height: auto; + display: block; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); + border: 1px solid var(--border); + background: var(--panel); +} + +.card-hover .dual { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-meta { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.6rem; + max-width: 320px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.card-meta ul { + margin: 0.25rem 0; + padding-left: 1.1rem; + list-style: disc; +} + +.card-meta li { + margin: 0.1rem 0; +} + +.card-meta .themes-list { + font-size: 18px; + line-height: 1.35; +} + +.card-meta .label { + color: #94a3b8; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.04em; + display: block; + margin-bottom: 0.15rem; +} + +.card-meta .themes-label { + color: var(--text); + font-size: 20px; + letter-spacing: 0.05em; +} + +.card-meta .line + .line { + margin-top: 0.35rem; +} + +.card-hover .themes-list li.overlap { + color: #0ea5e9; + font-weight: 600; +} + +.card-hover .ov-chip { + display: inline-block; + background: #38bdf8; + color: #102746; + border: 1px solid #0f3a57; + border-radius: 12px; + padding: 2px 6px; + font-size: 11px; + margin-right: 4px; + font-weight: 600; +} + +/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ + +.card-hover .dual.two-faced img { + width: 320px; +} + +.card-hover .dual.two-faced { + gap: 8px; +} + +/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ + +.card-hover .dual.combo img { + width: 300px; +} + +@media (max-width: 1100px) { + .card-hover .dual.two-faced img { + width: 280px; + } + + .card-hover .dual.combo img { + width: 260px; + } +} + +/* Hide hover preview on narrow screens to avoid covering content */ + +@media (max-width: 900px) { + .card-hover { + display: none !important; + } +} + +/* ============================================================================= + THEME BADGES (Moved from base.html 2025-10-21) + ============================================================================= */ + +.theme-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + font-size: 10px; + background: var(--panel-alt); + border: 1px solid var(--border); + letter-spacing: 0.5px; +} + +.theme-synergies { + font-size: 11px; + opacity: 0.85; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.badge-fallback { + background: #7f1d1d; + color: #fff; +} + +.badge-quality-draft { + background: #4338ca; + color: #fff; +} + +.badge-quality-reviewed { + background: #065f46; + color: #fff; +} + +.badge-quality-final { + background: #065f46; + color: #fff; + font-weight: 600; +} + +.badge-pop-vc { + background: #065f46; + color: #fff; +} + +.badge-pop-c { + background: #047857; + color: #fff; +} + +.badge-pop-u { + background: #0369a1; + color: #fff; +} + +.badge-pop-n { + background: #92400e; + color: #fff; +} + +.badge-pop-r { + background: #7f1d1d; + color: #fff; +} + +.badge-curated { + background: #4f46e5; + color: #fff; +} + +.badge-enforced { + background: #334155; + color: #fff; +} + +.badge-inferred { + background: #57534e; + color: #fff; +} + +.theme-detail-card { + background: var(--panel); + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +} + +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +.theme-detail-card h3 { + margin-top: 0; + margin-bottom: 0.4rem; +} + +.theme-detail-card .desc { + margin-top: 0; + font-size: 13px; + line-height: 1.45; +} + +.theme-detail-card h4 { + margin-bottom: 0.35rem; + margin-top: 0.85rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.85; +} + +.breadcrumb { + font-size: 12px; + margin-bottom: 0.4rem; +} + +/* ============================================================================= + HOVER CARD PANEL (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Unified hover-card-panel styling parity */ + +#hover-card-panel.is-payoff { + border-color: var(--accent, #38bdf8); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; +} + +#hover-card-panel.is-payoff .hcp-img { + border-color: var(--accent, #38bdf8); +} + +/* Two-column hover layout */ + +#hover-card-panel .hcp-body { + display: grid; + grid-template-columns: 320px 1fr; + gap: 18px; + align-items: start; +} + +#hover-card-panel .hcp-img-wrap { + grid-column: 1 / 2; +} + +#hover-card-panel.compact-img .hcp-body { + grid-template-columns: 120px 1fr; +} + +#hover-card-panel.hcp-simple { + width: auto !important; + max-width: min(360px, 90vw) !important; + padding: 12px !important; + height: auto !important; + max-height: none !important; + overflow: hidden !important; +} + +#hover-card-panel.hcp-simple .hcp-body { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +#hover-card-panel.hcp-simple .hcp-right { + display: none !important; +} + +#hover-card-panel.hcp-simple .hcp-img { + max-width: 100%; +} + +/* Tag list as multi-column list instead of pill chips for readability */ + +#hover-card-panel .hcp-taglist { + -moz-columns: 2; + columns: 2; + -moz-column-gap: 18px; + column-gap: 18px; + font-size: 13px; + line-height: 1.3; + margin: 6px 0 6px; + padding: 0; + list-style: none; + max-height: 180px; + overflow: auto; +} + +#hover-card-panel .hcp-taglist li { + -moz-column-break-inside: avoid; + break-inside: avoid; + padding: 2px 0 2px 0; + position: relative; +} + +#hover-card-panel .hcp-taglist li.overlap { + font-weight: 600; + color: var(--accent, #38bdf8); +} + +#hover-card-panel .hcp-taglist li.overlap::before { + content: '•'; + color: var(--accent, #38bdf8); + position: absolute; + left: -10px; +} + +#hover-card-panel .hcp-overlaps { + font-size: 10px; + line-height: 1.25; + margin-top: 2px; +} + +#hover-card-panel .hcp-ov-chip { + display: inline-flex; + align-items: center; + background: var(--accent, #38bdf8); + color: #102746; + border: 1px solid rgba(10, 54, 82, 0.6); + border-radius: 9999px; + padding: 3px 10px; + font-size: 13px; + margin-right: 6px; + margin-top: 4px; + font-weight: 500; + letter-spacing: 0.02em; +} + +/* Mobile hover panel */ + +#hover-card-panel.mobile { + left: 50% !important; + top: 50% !important; + bottom: auto !important; + transform: translate(-50%, -50%); + width: min(94vw, 460px) !important; + max-height: 88vh; + overflow-y: auto; + padding: 20px 22px; + pointer-events: auto !important; +} + +#hover-card-panel.mobile .hcp-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +#hover-card-panel.mobile .hcp-img { + width: 100%; + max-width: min(90vw, 420px) !important; + margin: 0 auto; +} + +#hover-card-panel.mobile .hcp-right { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-header { + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-role { + font-size: 12px; + letter-spacing: 0.55px; +} + +#hover-card-panel.mobile .hcp-meta { + font-size: 13px; + text-align: left; +} + +#hover-card-panel.mobile .hcp-overlaps { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { + margin: 0; +} + +#hover-card-panel.mobile .hcp-taglist { + -moz-columns: 1; + columns: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 2px; + max-height: none; + overflow: visible; + padding: 0; +} + +#hover-card-panel.mobile .hcp-taglist li { + background: rgba(37, 99, 235, 0.18); + border-radius: 9999px; + padding: 4px 10px; + display: inline-flex; + align-items: center; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap { + background: rgba(37, 99, 235, 0.28); + color: #dbeafe; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap::before { + display: none; +} + +#hover-card-panel.mobile .hcp-reasons { + max-height: 220px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-tags { + word-break: normal; + white-space: normal; + text-align: left; + width: 100%; + font-size: 12px; + opacity: 0.7; +} + +#hover-card-panel .hcp-close { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; + border-radius: 6px; + display: none; +} + +#hover-card-panel .hcp-close:focus { + outline: 2px solid rgba(59, 130, 246, 0.6); + outline-offset: 2px; +} + +#hover-card-panel.mobile .hcp-close { + display: inline-flex; +} + +/* Fade transition for hover panel image */ + +#hover-card-panel .hcp-img { + transition: opacity 0.22s ease; +} + +/* ============================================================================= + DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Hide modal-specific close button outside modal host */ + +#preview-close-btn { + display: none; +} + +#theme-preview-modal #preview-close-btn { + display: inline-flex; +} + +/* Overlay flip toggle for double-faced cards */ + +.dfc-host { + position: relative; +} + +.dfc-toggle { + position: absolute; + top: 6px; + left: 6px; + z-index: 5; + background: rgba(15, 23, 42, 0.82); + color: #fff; + border: 1px solid #475569; + border-radius: 50%; + width: 36px; + height: 36px; + padding: 0; + font-size: 16px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.92; + backdrop-filter: blur(3px); +} + +.dfc-toggle:hover, +.dfc-toggle:focus { + opacity: 1; + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); + outline: none; +} + +.dfc-toggle:active { + transform: translateY(1px); +} + +.dfc-toggle .icon { + font-size: 12px; +} + +.dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.85); +} + +.dfc-toggle[data-face='front'] { + background: rgba(15, 23, 42, 0.82); +} + +.dfc-toggle[aria-pressed='true'] { + box-shadow: 0 0 0 2px var(--accent, #38bdf8); +} + +.list-row .dfc-toggle { + position: static; + width: auto; + height: auto; + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + opacity: 1; + backdrop-filter: none; + margin-left: 4px; +} + +.list-row .dfc-toggle .icon { + font-size: 12px; +} + +.list-row .dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.3); +} + +.list-row .dfc-toggle[data-face='front'] { + background: rgba(56, 189, 248, 0.2); +} + +/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ + +/* ============================================================================= + SITE FOOTER (Moved from base.html 2025-10-21) + ============================================================================= */ + +.site-footer { + margin: 8px 16px; + padding: 8px 12px; + border-top: 1px solid var(--border); + color: #94a3b8; + font-size: 12px; + text-align: center; +} + +.site-footer a { + color: #cbd5e1; + text-decoration: underline; +} + +/* ============================================================================= + THEME PREVIEW FRAGMENT (themes/preview_fragment.html) + ============================================================================= */ + +/* Preview header */ + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.preview-header h3 { + margin: 0; + font-size: 16px; +} + +.preview-header .btn { + font-size: 12px; + line-height: 1; +} + +/* Preview controls */ + +.preview-controls { + display: flex; + gap: 1rem; + align-items: center; + margin: 0.5rem 0 0.75rem; + font-size: 11px; +} + +.preview-controls label { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.preview-controls .help-icon { + opacity: 0.55; + font-size: 10px; + cursor: help; +} + +.preview-controls #preview-status { + opacity: 0.65; +} + +/* Preview rationale */ + +.preview-rationale { + margin: 0.25rem 0 0.85rem; + font-size: 11px; + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 0.55rem 0.7rem; + border-radius: 8px; +} + +.preview-rationale summary { + cursor: pointer; + font-weight: 600; + letter-spacing: 0.05em; +} + +.preview-rationale-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} + +.preview-rationale-controls .btn { + font-size: 10px; + padding: 4px 8px; +} + +.preview-rationale-controls #hover-compact-indicator { + font-size: 10px; + opacity: 0.7; +} + +.preview-rationale ul { + margin: 0.5rem 0 0 0.9rem; + padding: 0; + list-style: disc; + line-height: 1.35; +} + +.preview-rationale li .detail { + opacity: 0.75; +} + +.preview-rationale li .instances { + opacity: 0.65; +} + +/* Two column layout */ + +.preview-two-col { + display: grid; + grid-template-columns: 1fr 480px; + gap: 1.25rem; + align-items: start; + position: relative; +} + +.preview-col-divider { + position: absolute; + top: 0; + bottom: 0; + left: calc(100% - 480px - 0.75rem); + width: 1px; + background: var(--border); + opacity: 0.55; +} + +/* Section headers */ + +.preview-section-header { + margin: 0.25rem 0 0.5rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.8; +} + +.preview-section-hr { + border: 0; + border-top: 1px solid var(--border); + margin: 0.35rem 0 0.6rem; +} + +/* Cards flow layout */ + +.cards-flow { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Group separators */ + +.group-separator { + flex-basis: 100%; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; + margin-top: 0.25rem; +} + +.group-separator.mt-larger { + margin-top: 0.5rem; +} + +/* Card sample */ + +.card-sample { + width: 230px; +} + +.card-sample .thumb-wrap { + position: relative; +} + +.card-sample img.card-thumb { + filter: blur(4px); + transition: filter 0.35s ease; + background: linear-gradient(145deg, #0b0d12, #111b29); +} + +.card-sample img.card-thumb[data-loaded] { + filter: blur(0); +} + +/* Card badges */ + +.dup-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: #4b5563; + color: #fff; + font-size: 10px; + padding: 2px 5px; + border-radius: 10px; +} + +.pin-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 10px; + padding: 2px 5px; + cursor: pointer; +} + +/* Card metadata */ + +.card-sample .meta { + font-size: 12px; + margin-top: 2px; +} + +.card-sample .ci-ribbon { + display: flex; + gap: 2px; + margin-bottom: 2px; + min-height: 10px; +} + +.card-sample .nm { + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-sample .mana-line { + min-height: 14px; + display: flex; + flex-wrap: wrap; + gap: 2px; + font-size: 10px; +} + +.card-sample .rarity-badge { + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.7; +} + +.card-sample .role { + opacity: 0.75; + font-size: 11px; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.card-sample .reasons { + font-size: 9px; + opacity: 0.55; + line-height: 1.15; +} + +/* Synthetic card */ + +.card-sample.synthetic { + border: 1px dashed var(--border); + padding: 8px; + border-radius: 10px; + background: var(--panel-alt); +} + +.card-sample.synthetic .name { + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.card-sample.synthetic .roles { + font-size: 11px; + opacity: 0.8; +} + +.card-sample.synthetic .reasons-text { + font-size: 10px; + margin-top: 2px; + opacity: 0.6; + line-height: 1.15; +} + +/* Spacer */ + +.full-width-spacer { + flex-basis: 100%; + height: 0; +} + +/* Commander grid */ + +.commander-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1rem; +} + +.commander-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: center; +} + +.commander-name { + font-size: 13px; + text-align: center; + line-height: 1.35; + font-weight: 600; + max-width: 230px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commander-cell.synergy .commander-name { + font-size: 12px; + line-height: 1.3; + font-weight: 500; + opacity: 0.92; +} + +/* Synergy commanders section */ + +.synergy-commanders-section { + margin-top: 1rem; +} + +.synergy-commanders-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} + +.synergy-commanders-header h5 { + margin: 0; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.75; +} + +.derived-badge { + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + line-height: 1; +} + +/* No commanders message */ + +.no-commanders-message { + font-size: 11px; + opacity: 0.7; +} + +/* Footer help text */ + +.preview-help-text { + margin-top: 1rem; + font-size: 10px; + opacity: 0.65; + line-height: 1.4; +} + +/* Skeleton loader */ + +.preview-skeleton .sk-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-skeleton .sk-bar { + height: 16px; + background: var(--hover); + border-radius: 4px; +} + +.preview-skeleton .sk-bar.title { + width: 200px; +} + +.preview-skeleton .sk-bar.close { + width: 60px; +} + +.preview-skeleton .sk-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 1rem; +} + +.preview-skeleton .sk-card { + width: 230px; + height: 327px; + background: var(--hover); + border-radius: 10px; +} + +/* Responsive */ + +@media (max-width: 950px) { + .preview-two-col { + grid-template-columns: 1fr; + } + + .preview-two-col .col-right { + order: -1; + } +} + +footer.site-footer { + flex-shrink: 0; +} + diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css new file mode 100644 index 0000000..f8d085c --- /dev/null +++ b/code/web/static/tailwind.css @@ -0,0 +1,3537 @@ +/* Tailwind CSS Entry Point */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Import custom CSS (not purged by Tailwind) */ +@import './custom.css'; + +/* Base */ +:root{ + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; /* focus ring */ + --ok: #16a34a; /* success */ + --warn: #f59e0b; /* warning */ + --err: #ef4444; /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); +} + +/* Light blend between Slate and Parchment (leans gray) */ +[data-theme="light-blend"]{ + --bg: #e8e2d0; /* warm beige background (keep existing) */ + --panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */ + --text: #0d0a08; /* very dark brown/near-black for strong readability */ + --muted: #5a544c; /* darker muted brown for better contrast */ + --border: #bfb5a3; /* darker warm-gray border for better definition */ + /* Navbar/banner: darker warm brown for hierarchy */ + --surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */ + --surface-sidebar: #9b8f7a; /* match banner for consistency */ + --surface-banner-text: #1a1410; /* dark brown text on medium brown bg */ + --surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */ + /* Button colors: use taupe for buttons so they stand out from light panels */ + --btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */ + --btn-text: #1a1410; /* dark brown text */ + --btn-hover-bg: #c4b9a5; /* darker taupe on hover */ +} + +[data-theme="dark"]{ + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; +} +[data-theme="high-contrast"]{ + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; +} +[data-theme="cb-friendly"]{ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ +} +*{box-sizing:border-box} +html{height:100%; overflow-x:hidden; overflow-y:scroll; max-width:100vw;} +body { + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: scroll; +} +/* Honor HTML hidden attribute across the app */ +[hidden] { display: none !important; } +/* Accessible focus ring for keyboard navigation */ +.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* Top banner - simplified, no changes on sidebar toggle */ +.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); box-shadow:0 2px 6px rgba(0,0,0,.4); min-height: var(--banner-h); } +.top-banner .top-inner{ margin:0; padding:.4rem 15px; display:flex; align-items:center; width:100%; box-sizing:border-box; } +.top-banner h1{ font-size: 1.1rem; margin:0; margin-left: 25px; } +.flex-row{ display: flex; align-items: center; gap: 25px; } +.top-banner .banner-left{ width: 260px !important; flex-shrink: 0 !important; } +/* Hide elements on all screen sizes */ +#btn-open-permalink{ display:none !important; } +#banner-status{ display:none !important; } +.top-banner #theme-reset{ display:none !important; } + +/* Layout */ +.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} +.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } + +/* Collapsible sidebar behavior */ +body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } +body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } +body.nav-collapsed .content{ grid-column: 2; } +/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ +/* Smooth hide/show on mobile while keeping fixed positioning */ +.sidebar{ transition: transform .2s ease-out, visibility .2s linear; overflow-x: hidden; } +/* Suppress sidebar transitions during page load to prevent pop-in */ +body.no-transition .sidebar{ transition: none !important; } +/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ +body.htmx-settling .sidebar{ transition: none !important; } +body.htmx-settling .layout{ transition: none !important; } +body.htmx-settling .content{ transition: none !important; } +body.htmx-settling *{ transition-duration: 0s !important; } + +/* Mobile tweaks */ +@media (max-width: 900px){ + :root{ --sidebar-w: 240px; } + .layout{ grid-template-columns: 0 1fr; } + .sidebar{ transform: translateX(-100%); visibility: hidden; } + body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } + body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } + .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } +} + +/* Additional mobile spacing for bottom floating controls */ +@media (max-width: 720px) { + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } +} + +.brand h1{ display:none; } +.brand{ padding-top: 0; margin-top: 0; } +.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; margin-top: 0; padding-top: 0; } +.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } +.dot.green{ background: var(--green-main); } +.dot.blue{ background: var(--blue-main); } +.dot.red{ background: var(--red-main); } +.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } +.dot.black{ background: var(--black-light); } + +.nav{ display:flex; flex-direction:column; gap:.35rem; } +.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } +.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } + +/* Sidebar theme controls anchored at bottom */ +.sidebar .nav { flex: 1 1 auto; } +.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } +.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; flex-wrap: nowrap; } +.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; flex: 1 1 auto; min-width: 0; } +.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); flex-shrink: 0; white-space: nowrap; } + +/* Simple two-column layout for inspect panel */ +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } +.two-col .grow { min-width: 0; } +.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } +@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +/* Left-rail variant puts the image first */ +.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } +/* Ensure left-rail variant also collapses to 1 column on small screens */ +@media (max-width: 900px){ + .two-col.two-col-left-rail{ grid-template-columns: 1fr; } + /* So the commander image doesn't dominate on mobile */ + .two-col .card-preview{ max-width: 360px; margin: 0 auto; } + .two-col .card-preview img{ width: 100%; height: auto; } +} +.card-preview.card-sm{ max-width:200px; } + +/* Buttons, inputs */ +button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } +button:hover{ filter:brightness(1.05); } +/* Anchor-style buttons */ +.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } +.btn:hover{ filter:brightness(1.05); text-decoration:none; } +.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } +label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } +select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } +/* Range slider styling */ +input[type="range"]{ + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 8px; + background: var(--bg); + border-radius: 4px; + outline: none; + border: 1px solid var(--border); +} +input[type="range"]::-webkit-slider-thumb{ + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + background: var(--blue-main); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--panel); + box-shadow: 0 2px 4px rgba(0,0,0,.2); +} +input[type="range"]::-moz-range-thumb{ + width: 20px; + height: 20px; + background: var(--blue-main); + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--panel); + box-shadow: 0 2px 4px rgba(0,0,0,.2); +} +fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } +small, .muted{ color: var(--muted); } +.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } +.partner-preview[hidden]{ display:none !important; } +.partner-preview__header{ font-weight:600; } +.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } +.partner-preview__art{ flex:0 0 auto; } +.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } +.partner-preview__details{ flex:1 1 180px; min-width:0; } +.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } +.partner-preview__pairing{ margin-top:.35rem; } +.partner-preview__themes{ margin-top:.35rem; font-size:12px; } +.partner-preview--static{ margin-bottom:.5rem; } +.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +/* Toasts */ +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } + +/* Skeletons */ +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } +[data-skeleton][data-skeleton-overlay="false"]::after, +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton]::after{ + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; +} +[data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } +@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } + +/* Banner */ +.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } +.banner h1{ font-size: 2rem; margin:0 0 .35rem; } +.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +/* Home actions */ +.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } +.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } +.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } +.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +/* Home page darker buttons */ +.home-button.btn-secondary { + background: var(--btn-bg, #1a1d24); + color: var(--btn-text, #e8e8e8); + border-color: var(--border); +} +.home-button.btn-secondary:hover { + background: var(--btn-hover-bg, #22252d); + border-color: var(--border); +} +.home-button.btn-primary { + background: var(--blue-main); + color: white; + border-color: var(--blue-main); +} +.home-button.btn-primary:hover { + background: #0c5aa6; + border-color: #0c5aa6; +} + +/* Card grid for added cards (responsive, compact tiles) */ +.card-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; +} +@media (max-width: 420px){ + .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-tile{ width: 100%; } + .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } +} +.card-tile{ + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; +} +.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } +.card-tile.locked{ + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; +} +.card-tile.must-include{ + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); +} +.card-tile.must-exclude{ + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; +} +.card-tile.must-include.must-exclude{ + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} +.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } +.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } +.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } + +.must-have-controls{ + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; +} +.must-have-btn{ + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; +} +.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); +} +.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); +} +.must-have-btn:focus-visible{ + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; +} +.card-tile.must-exclude .must-have-btn.include[data-active="0"], +.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ + opacity:.65; +} + +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } +.alt-option{ display:block !important; width:100%; max-width:100%; text-align:left; white-space:normal !important; word-wrap:break-word !important; overflow-wrap:break-word !important; line-height:1.3 !important; padding:0.5rem 0.7rem !important; } + +/* Shared ownership badge for card tiles and stacked images */ +.owned-badge{ + position:absolute; + top:6px; + left:6px; + background:var(--panel); + color:var(--text); + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; +} + +/* Step 1 candidate grid (200px-wide scaled images) */ +.candidate-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; +} +.candidate-tile{ + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} +.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } +.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } +.candidate-tile .meta{ text-align:center; margin-top:.35rem; } +.candidate-tile .name{ font-weight:600; font-size:.95rem; } +.candidate-tile .score{ color:var(--muted); font-size:.85rem; } + +/* Deck summary: highlight game changers */ +.game-changer { color: var(--green-main); } +.stack-card.game-changer { outline: 2px solid var(--green-main); } + +/* Image button inside card tiles */ +.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +/* Stage Navigator */ +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:var(--bg); font-size:12px; } +.stage-nav .name { font-size:12px; } + +/* Build controls sticky box tweaks */ +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: var(--panel); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); +} + +@media (max-width: 1024px){ + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } +} +@media (min-width: 721px){ + :root { --banner-offset: 48px; } +} + +/* Progress bar */ +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +/* Chips */ +.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } +.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } +.chip:hover { background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); } +.chip.active { + background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); + border-color: #3b82f6; + color: #60a5fa; + font-weight: 600; + box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; +} +.chip.active:hover { + background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); + border-color: #60a5fa; +} + +/* Cards toolbar */ +.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } +.cards-toolbar input[type="text"]{ min-width: 220px; } +.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } +.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +/* Collapse groups and reason toggle */ +.group{ margin:.5rem 0; } +.group-header{ display:flex; align-items:center; gap:.5rem; } +.group-header h5{ margin:.4rem 0; } +.group-header .count{ color: var(--muted); font-size:12px; } +.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } +.group-grid[data-collapsed]{ display:none; } +.hide-reasons .card-tile .reason{ display:none; } +.card-tile.force-show .reason{ display:block !important; } +.card-tile.force-hide .reason{ display:none !important; } +.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } +.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } +.chips-inline .chip{ cursor:pointer; user-select:none; } + +/* Inline error banner */ +.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } +.inline-error-banner .muted{ color:#fda4af; } + +/* Alternatives panel */ +.alts ul{ list-style:none; padding:0; margin:0; } +.alts li{ display:flex; align-items:center; gap:.4rem; } +/* LQIP blur/fade-in for thumbnails */ +img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } +img.lqip.loaded { filter: blur(0); opacity: 1; } + +/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; } + img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } +} + +/* Virtualization wrapper should mirror grid to keep multi-column flow */ +.virt-wrapper { display: grid; } + +/* Mobile responsive fixes for horizontal scrolling issues */ +@media (max-width: 768px) { + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } + + /* Test hand responsive adjustments */ + #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + + /* Modal & form layout fixes (original block retained inside media query) */ + /* Fix modal layout on mobile */ + .modal { + padding: 10px !important; + box-sizing: border-box; + } + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + /* Force single column for include/exclude grid */ + .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } + /* Fix basics grid */ + .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } + /* Ensure all inputs and textareas fit properly */ + .modal input, + .modal textarea, + .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } + /* Fix chips containers */ + .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } + /* Ensure fieldsets don't overflow */ + .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } + /* Fix any inline styles that might cause overflow */ + .modal fieldset > div, + .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } +} + +@media (max-width: 640px){ + #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } + /* Generic stack shrink */ + .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } +} + +@media (max-width: 560px){ + #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } + #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } + #test-hand .stack-card{ flex:0 0 auto; } + .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } +} + +@media (max-width: 480px) { + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; /* Much smaller padding */ + font-size: 12px !important; /* Smaller font */ + min-height: 36px !important; /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + .build-controls .sep, + .build-controls .replace-toggle, + .build-controls label[style*="margin-left"] { + display: none !important; + } + + .build-controls .sep { + display: none !important; /* Hide separators on mobile */ + } +} + +/* Desktop sizing for Test Hand */ +@media (min-width: 900px) { + #test-hand { --card-w: 280px !important; --card-h: 392px !important; } +} + +/* Analytics accordion styling */ +.analytics-accordion { + transition: all 0.2s ease; +} + +.analytics-accordion summary { + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.analytics-accordion summary:hover { + background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); + border-color: var(--text); +} + +.analytics-accordion summary:active { + transform: scale(0.99); +} + +.analytics-accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.analytics-accordion .analytics-content { + animation: accordion-slide-down 0.3s ease-out; +} + +@keyframes accordion-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-placeholder .skeleton-pulse { + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Ideals Slider Styling */ +.ideals-slider { + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; +} + +.ideals-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.ideals-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.slider-value { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Card Details button on tiles */ +.card-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; +} + +.card-details-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); +} + +.card-details-btn svg { + flex-shrink: 0; +} + +/* Card Preview Modal */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.preview-modal.active { + display: flex; +} + +.preview-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.preview-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.preview-close { + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.preview-close:hover { + background: #fff; + transform: scale(1.1); +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} + +/* Card Detail Page Styles */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.card-tag { + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; +} + +.back-button:hover { + background: var(--ring); + color: white; + border-color: var(--ring); +} + +/* Card Detail Page - Main Card Image */ +.card-image-large { + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; +} + +.card-image-large img { + width: 100%; + height: auto; + border-radius: 12px; +} + +/* ============================================ + M2 Component Library Styles + ============================================ */ + +/* === BUTTONS === */ +/* Button Base - enhanced from existing .btn */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--blue-main); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + text-decoration: none; + line-height: 1.5; + font-weight: 500; + transition: filter 0.15s ease, transform 0.05s ease; + white-space: nowrap; +} + +.btn:hover { + filter: brightness(1.1); + text-decoration: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn.disabled, +.btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Button Variants */ +.btn-primary { + background: var(--blue-main); + color: #fff; +} + +.btn-secondary { + background: var(--muted); + color: var(--text); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + background: var(--panel); + border-color: var(--text); +} + +.btn-danger { + background: var(--err); + color: #fff; +} + +/* Button Sizes */ +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + +.btn-md { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Icon Button */ +.btn-icon { + padding: 0.5rem; + aspect-ratio: 1; + justify-content: center; +} + +.btn-icon.btn-sm { + padding: 0.25rem; + font-size: 1rem; +} + +/* Close Button */ +.btn-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.5rem; + line-height: 1; + z-index: 10; +} + +/* Tag/Chip Button */ +.btn-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.btn-tag:hover { + background: var(--border); + border-color: var(--text); +} + +.btn-tag-selected { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.btn-tag-remove { + background: transparent; + border: none; + color: inherit; + padding: 0; + margin: 0; + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.btn-tag-remove:hover { + opacity: 1; +} + +/* Button Group */ +.btn-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-group-left { + justify-content: flex-start; +} + +.btn-group-center { + justify-content: center; +} + +.btn-group-right { + justify-content: flex-end; +} + +.btn-group-between { + justify-content: space-between; +} + +/* Legacy action-btn compatibility */ +.action-btn { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* === MODALS === */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + z-index: -1; +} + +.modal-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + width: 100%; + max-height: min(92vh, 100%); + display: flex; + flex-direction: column; +} + +/* Modal Sizes */ +.modal-sm .modal-content { + max-width: 480px; +} + +.modal-md .modal-content { + max-width: 620px; +} + +.modal-lg .modal-content { + max-width: 720px; +} + +.modal-xl .modal-content { + max-width: 960px; +} + +/* Modal Position */ +.modal-center { + align-items: center; +} + +.modal-top { + align-items: flex-start; + padding-top: 2rem; +} + +/* Modal Scrollable */ +.modal-scrollable .modal-content { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Modal Structure */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-right: 2rem; +} + +.modal-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +/* Modal Variants */ +.modal-confirm .modal-body { + padding: 1rem 0; + font-size: 0.95rem; +} + +.modal-alert { + text-align: center; +} + +.modal-alert .modal-body { + padding: 1.5rem 0; +} + +.modal-alert .alert-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.modal-alert-info .alert-icon::before { + content: 'ℹ️'; +} + +.modal-alert-success .alert-icon::before { + content: '✅'; +} + +.modal-alert-warning .alert-icon::before { + content: '⚠️'; +} + +.modal-alert-error .alert-icon::before { + content: '❌'; +} + +/* === FORMS === */ +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.form-label { + font-weight: 500; + font-size: 0.875rem; + color: var(--text); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.form-required { + color: var(--err); + font-weight: bold; +} + +.form-input-wrapper { + display: flex; + flex-direction: column; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-input:disabled, +.form-textarea:disabled, +.form-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-input-number { + max-width: 150px; +} + +.form-input-file { + padding: 0.375rem 0.5rem; +} + +/* Checkbox and Radio */ +.form-field-checkbox, +.form-field-radio { + flex-direction: row; + align-items: flex-start; +} + +.form-checkbox-label, +.form-radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: normal; +} + +.form-checkbox, +.form-radio { + width: 1.125rem; + height: 1.125rem; + border: 1px solid var(--border); + cursor: pointer; + flex-shrink: 0; +} + +.form-checkbox { + border-radius: 4px; +} + +.form-radio { + border-radius: 50%; +} + +.form-checkbox:checked, +.form-radio:checked { + background: var(--blue-main); + border-color: var(--blue-main); +} + +.form-checkbox:focus, +.form-radio:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Form Help and Error Text */ +.form-help-text { + font-size: 0.8rem; + color: var(--muted); + margin-top: -0.25rem; +} + +.form-error-text { + font-size: 0.8rem; + color: var(--err); + margin-top: -0.25rem; +} + +.form-field-error .form-input, +.form-field-error .form-textarea, +.form-field-error .form-select { + border-color: var(--err); +} + +/* === CARD DISPLAY COMPONENTS === */ +/* Card Thumbnail Container */ +.card-thumb-container { + position: relative; + display: inline-block; +} + +.card-thumb { + display: block; + border-radius: 10px; + border: 1px solid var(--border); + background: #0b0d12; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-thumb:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); +} + +/* Card Thumbnail Sizes */ +.card-thumb-small .card-thumb { + width: 160px; + height: auto; +} + +.card-thumb-medium .card-thumb { + width: 230px; + height: auto; +} + +.card-thumb-large .card-thumb { + width: 360px; + height: auto; +} + +/* Card Flip Button */ +.card-flip-btn { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background 0.15s ease; + z-index: 5; +} + +.card-flip-btn:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.4); +} + +.card-flip-btn svg { + width: 16px; + height: 16px; +} + +/* Card Name Label */ +.card-name-label { + font-size: 0.75rem; + margin-top: 0.375rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + text-align: center; +} + +/* Card Hover Popup */ +.card-popup { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.card-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); + z-index: -1; +} + +.card-popup-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-width: 400px; + width: 100%; +} + +.card-popup-image { + position: relative; + margin-bottom: 1rem; +} + +.card-popup-image img { + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--border); +} + +.card-popup-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-popup-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.card-popup-role { + font-size: 0.875rem; + color: var(--muted); +} + +.card-popup-role span { + color: var(--text); + font-weight: 500; +} + +.card-popup-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.card-popup-tag { + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; +} + +.card-popup-tag-highlight { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.card-popup-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: none; + border-radius: 6px; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.card-popup-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +/* Card Grid */ +.card-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.card-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.card-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +.card-grid-cols-5 { + grid-template-columns: repeat(5, 1fr); +} + +.card-grid-cols-6 { + grid-template-columns: repeat(6, 1fr); +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* Card List */ +.card-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + transition: background 0.15s ease; +} + +.card-list-item:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.card-list-item-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.card-list-item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-list-item-count { + color: var(--muted); + font-size: 0.875rem; +} + +.card-list-item-role { + color: var(--muted); + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +/* Synthetic Card Placeholder */ +.card-sample.synthetic { + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--panel); + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.synthetic-card-placeholder { + text-align: center; +} + +.synthetic-card-icon { + font-size: 2rem; + opacity: 0.5; + margin-bottom: 0.5rem; +} + +.synthetic-card-name { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.synthetic-card-reason { + font-size: 0.75rem; + color: var(--muted); +} + +/* === PANELS === */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.75rem; +} + +/* Panel Variants */ +.panel-default { + background: var(--panel); +} + +.panel-alt { + background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); +} + +.panel-dark { + background: #0f1115; +} + +.panel-bordered { + background: transparent; +} + +/* Panel Padding */ +.panel-padding-none { + padding: 0; +} + +.panel-padding-sm { + padding: 0.5rem; +} + +.panel-padding-md { + padding: 0.75rem; +} + +.panel-padding-lg { + padding: 1.5rem; +} + +/* Panel Structure */ +.panel-header { + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.panel-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.panel-body { + padding: 0.75rem; +} + +.panel-footer { + padding: 0.75rem; + border-top: 1px solid var(--border); +} + +/* Info Panel */ +.panel-info { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.panel-info-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; +} + +.panel-info-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.panel-info-text { + flex: 1; +} + +.panel-info-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem; + color: var(--text); +} + +.panel-info-message { + font-size: 0.875rem; + color: var(--muted); +} + +.panel-info-action { + flex-shrink: 0; +} + +/* Info Panel Variants */ +.panel-info-info { + border-color: var(--ring); + background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); +} + +.panel-info-success { + border-color: var(--ok); + background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); +} + +.panel-info-warning { + border-color: var(--warn); + background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); +} + +.panel-info-error { + border-color: var(--err); + background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); +} + +/* Stat Panel */ +.panel-stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + flex-direction: column; +} + +.panel-stat-icon { + font-size: 2rem; +} + +.panel-stat-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.panel-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: var(--text); +} + +.panel-stat-label { + font-size: 0.875rem; + color: var(--muted); + margin-top: 0.25rem; +} + +.panel-stat-sublabel { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.125rem; +} + +/* Stat Panel Variants */ +.panel-stat-primary { + border-color: var(--ring); +} + +.panel-stat-primary .panel-stat-value { + color: var(--ring); +} + +.panel-stat-success { + border-color: var(--ok); +} + +.panel-stat-success .panel-stat-value { + color: var(--ok); +} + +.panel-stat-warning { + border-color: var(--warn); +} + +.panel-stat-warning .panel-stat-value { + color: var(--warn); +} + +.panel-stat-error { + border-color: var(--err); +} + +.panel-stat-error .panel-stat-value { + color: var(--err); +} + +/* Collapsible Panel */ +.panel-collapsible .panel-header { + padding: 0; + border: none; +} + +.panel-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + text-align: left; + border-radius: 10px 10px 0 0; + transition: background 0.15s ease; +} + +.panel-toggle:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.panel-toggle-icon { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid var(--text); + transition: transform 0.2s ease; +} + +.panel-collapsed .panel-toggle-icon { + transform: rotate(-90deg); +} + +.panel-expanded .panel-toggle-icon { + transform: rotate(0deg); +} + +.panel-collapse-content { + overflow: hidden; + transition: max-height 0.3s ease; +} + +/* Panel Grid */ +.panel-grid { + display: grid; + gap: 1rem; +} + +.panel-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.panel-grid-cols-1 { + grid-template-columns: 1fr; +} + +.panel-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.panel-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.panel-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 768px) { + .panel-grid { + grid-template-columns: 1fr; + } +} + +/* Empty State Panel */ +.panel-empty-state { + text-align: center; + padding: 3rem 1.5rem; +} + +.panel-empty-icon { + font-size: 4rem; + opacity: 0.5; + margin-bottom: 1rem; +} + +.panel-empty-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--text); +} + +.panel-empty-message { + font-size: 0.95rem; + color: var(--muted); + margin: 0 0 1.5rem; +} + +.panel-empty-action { + display: flex; + justify-content: center; +} + +/* Loading Panel */ +.panel-loading { + text-align: center; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.panel-loading-spinner { + width: 3rem; + height: 3rem; + border: 4px solid var(--border); + border-top-color: var(--ring); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.panel-loading-message { + font-size: 0.95rem; + color: var(--muted); +} + +/* ============================================================================= + UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) + ============================================================================= */ + +/* Flex Row Layouts */ +.flex-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.flex-row-sm { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.flex-row-md { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.flex-row-lg { + display: flex; + align-items: center; + gap: 1rem; +} + +.flex-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.flex-row-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.flex-row-start { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* Flex Column Layouts */ +.flex-col { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.flex-col-sm { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.flex-col-md { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flex-col-lg { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.flex-col-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +/* Flex Grid/Wrap Patterns */ +.flex-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.flex-grid-sm { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.flex-grid-md { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.flex-grid-lg { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +/* Spacing Utilities */ +.section-spacing { + margin-top: 2rem; +} + +.section-spacing-sm { + margin-top: 1rem; +} + +.section-spacing-lg { + margin-top: 3rem; +} + +.content-spacing { + margin-bottom: 1rem; +} + +.content-spacing-sm { + margin-bottom: 0.5rem; +} + +.content-spacing-lg { + margin-bottom: 2rem; +} + +/* Common Size Constraints */ +.max-w-content { + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.max-w-prose { + max-width: 65ch; + margin-left: auto; + margin-right: auto; +} + +.max-w-form { + max-width: 600px; +} + +/* Common Text Patterns */ +.text-muted { + color: var(--muted); + opacity: 0.85; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1.25; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.35; +} + +.text-base { + font-size: 1rem; + line-height: 1.5; +} + +/* Screen Reader Only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================================================= + CARD HOVER SYSTEM (Moved from base.html 2025-10-21) + ============================================================================= */ + +.card-hover { + position: fixed; + pointer-events: none; + z-index: 9999; + display: none; +} + +.card-hover-inner { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-hover img { + width: 320px; + height: auto; + display: block; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); + border: 1px solid var(--border); + background: var(--panel); +} + +.card-hover .dual { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-meta { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.6rem; + max-width: 320px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.card-meta ul { + margin: 0.25rem 0; + padding-left: 1.1rem; + list-style: disc; +} + +.card-meta li { + margin: 0.1rem 0; +} + +.card-meta .themes-list { + font-size: 18px; + line-height: 1.35; +} + +.card-meta .label { + color: #94a3b8; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.04em; + display: block; + margin-bottom: 0.15rem; +} + +.card-meta .themes-label { + color: var(--text); + font-size: 20px; + letter-spacing: 0.05em; +} + +.card-meta .line + .line { + margin-top: 0.35rem; +} + +.card-hover .themes-list li.overlap { + color: #0ea5e9; + font-weight: 600; +} + +.card-hover .ov-chip { + display: inline-block; + background: #38bdf8; + color: #102746; + border: 1px solid #0f3a57; + border-radius: 12px; + padding: 2px 6px; + font-size: 11px; + margin-right: 4px; + font-weight: 600; +} + +/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ +.card-hover .dual.two-faced img { + width: 320px; +} + +.card-hover .dual.two-faced { + gap: 8px; +} + +/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ +.card-hover .dual.combo img { + width: 300px; +} + +@media (max-width: 1100px) { + .card-hover .dual.two-faced img { + width: 280px; + } + .card-hover .dual.combo img { + width: 260px; + } +} + +/* Hide hover preview on narrow screens to avoid covering content */ +@media (max-width: 900px) { + .card-hover { + display: none !important; + } +} + +/* ============================================================================= + THEME BADGES (Moved from base.html 2025-10-21) + ============================================================================= */ + +.theme-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + font-size: 10px; + background: var(--panel-alt); + border: 1px solid var(--border); + letter-spacing: 0.5px; +} + +.theme-synergies { + font-size: 11px; + opacity: 0.85; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.badge-fallback { + background: #7f1d1d; + color: #fff; +} + +.badge-quality-draft { + background: #4338ca; + color: #fff; +} + +.badge-quality-reviewed { + background: #065f46; + color: #fff; +} + +.badge-quality-final { + background: #065f46; + color: #fff; + font-weight: 600; +} + +.badge-pop-vc { + background: #065f46; + color: #fff; +} + +.badge-pop-c { + background: #047857; + color: #fff; +} + +.badge-pop-u { + background: #0369a1; + color: #fff; +} + +.badge-pop-n { + background: #92400e; + color: #fff; +} + +.badge-pop-r { + background: #7f1d1d; + color: #fff; +} + +.badge-curated { + background: #4f46e5; + color: #fff; +} + +.badge-enforced { + background: #334155; + color: #fff; +} + +.badge-inferred { + background: #57534e; + color: #fff; +} + +.theme-detail-card { + background: var(--panel); + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +} + +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +.theme-detail-card h3 { + margin-top: 0; + margin-bottom: 0.4rem; +} + +.theme-detail-card .desc { + margin-top: 0; + font-size: 13px; + line-height: 1.45; +} + +.theme-detail-card h4 { + margin-bottom: 0.35rem; + margin-top: 0.85rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.85; +} + +.breadcrumb { + font-size: 12px; + margin-bottom: 0.4rem; +} + +/* ============================================================================= + HOVER CARD PANEL (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Unified hover-card-panel styling parity */ +#hover-card-panel.is-payoff { + border-color: var(--accent, #38bdf8); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; +} + +#hover-card-panel.is-payoff .hcp-img { + border-color: var(--accent, #38bdf8); +} + +/* Two-column hover layout */ +#hover-card-panel .hcp-body { + display: grid; + grid-template-columns: 320px 1fr; + gap: 18px; + align-items: start; +} + +#hover-card-panel .hcp-img-wrap { + grid-column: 1 / 2; +} + +#hover-card-panel.compact-img .hcp-body { + grid-template-columns: 120px 1fr; +} + +#hover-card-panel.hcp-simple { + width: auto !important; + max-width: min(360px, 90vw) !important; + padding: 12px !important; + height: auto !important; + max-height: none !important; + overflow: hidden !important; +} + +#hover-card-panel.hcp-simple .hcp-body { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +#hover-card-panel.hcp-simple .hcp-right { + display: none !important; +} + +#hover-card-panel.hcp-simple .hcp-img { + max-width: 100%; +} + +/* Tag list as multi-column list instead of pill chips for readability */ +#hover-card-panel .hcp-taglist { + columns: 2; + column-gap: 18px; + font-size: 13px; + line-height: 1.3; + margin: 6px 0 6px; + padding: 0; + list-style: none; + max-height: 180px; + overflow: auto; +} + +#hover-card-panel .hcp-taglist li { + break-inside: avoid; + padding: 2px 0 2px 0; + position: relative; +} + +#hover-card-panel .hcp-taglist li.overlap { + font-weight: 600; + color: var(--accent, #38bdf8); +} + +#hover-card-panel .hcp-taglist li.overlap::before { + content: '•'; + color: var(--accent, #38bdf8); + position: absolute; + left: -10px; +} + +#hover-card-panel .hcp-overlaps { + font-size: 10px; + line-height: 1.25; + margin-top: 2px; +} + +#hover-card-panel .hcp-ov-chip { + display: inline-flex; + align-items: center; + background: var(--accent, #38bdf8); + color: #102746; + border: 1px solid rgba(10, 54, 82, 0.6); + border-radius: 9999px; + padding: 3px 10px; + font-size: 13px; + margin-right: 6px; + margin-top: 4px; + font-weight: 500; + letter-spacing: 0.02em; +} + +/* Mobile hover panel */ +#hover-card-panel.mobile { + left: 50% !important; + top: 50% !important; + bottom: auto !important; + transform: translate(-50%, -50%); + width: min(94vw, 460px) !important; + max-height: 88vh; + overflow-y: auto; + padding: 20px 22px; + pointer-events: auto !important; +} + +#hover-card-panel.mobile .hcp-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +#hover-card-panel.mobile .hcp-img { + width: 100%; + max-width: min(90vw, 420px) !important; + margin: 0 auto; +} + +#hover-card-panel.mobile .hcp-right { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-header { + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-role { + font-size: 12px; + letter-spacing: 0.55px; +} + +#hover-card-panel.mobile .hcp-meta { + font-size: 13px; + text-align: left; +} + +#hover-card-panel.mobile .hcp-overlaps { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { + margin: 0; +} + +#hover-card-panel.mobile .hcp-taglist { + columns: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 2px; + max-height: none; + overflow: visible; + padding: 0; +} + +#hover-card-panel.mobile .hcp-taglist li { + background: rgba(37, 99, 235, 0.18); + border-radius: 9999px; + padding: 4px 10px; + display: inline-flex; + align-items: center; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap { + background: rgba(37, 99, 235, 0.28); + color: #dbeafe; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap::before { + display: none; +} + +#hover-card-panel.mobile .hcp-reasons { + max-height: 220px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-tags { + word-break: normal; + white-space: normal; + text-align: left; + width: 100%; + font-size: 12px; + opacity: 0.7; +} + +#hover-card-panel .hcp-close { + appearance: none; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; + border-radius: 6px; + display: none; +} + +#hover-card-panel .hcp-close:focus { + outline: 2px solid rgba(59, 130, 246, 0.6); + outline-offset: 2px; +} + +#hover-card-panel.mobile .hcp-close { + display: inline-flex; +} + +/* Fade transition for hover panel image */ +#hover-card-panel .hcp-img { + transition: opacity 0.22s ease; +} + +/* ============================================================================= + DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Hide modal-specific close button outside modal host */ +#preview-close-btn { + display: none; +} + +#theme-preview-modal #preview-close-btn { + display: inline-flex; +} + +/* Overlay flip toggle for double-faced cards */ +.dfc-host { + position: relative; +} + +.dfc-toggle { + position: absolute; + top: 6px; + left: 6px; + z-index: 5; + background: rgba(15, 23, 42, 0.82); + color: #fff; + border: 1px solid #475569; + border-radius: 50%; + width: 36px; + height: 36px; + padding: 0; + font-size: 16px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.92; + backdrop-filter: blur(3px); +} + +.dfc-toggle:hover, +.dfc-toggle:focus { + opacity: 1; + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); + outline: none; +} + +.dfc-toggle:active { + transform: translateY(1px); +} + +.dfc-toggle .icon { + font-size: 12px; +} + +.dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.85); +} + +.dfc-toggle[data-face='front'] { + background: rgba(15, 23, 42, 0.82); +} + +.dfc-toggle[aria-pressed='true'] { + box-shadow: 0 0 0 2px var(--accent, #38bdf8); +} + +.list-row .dfc-toggle { + position: static; + width: auto; + height: auto; + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + opacity: 1; + backdrop-filter: none; + margin-left: 4px; +} + +.list-row .dfc-toggle .icon { + font-size: 12px; +} + +.list-row .dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.3); +} + +.list-row .dfc-toggle[data-face='front'] { + background: rgba(56, 189, 248, 0.2); +} + +/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ + +/* ============================================================================= + SITE FOOTER (Moved from base.html 2025-10-21) + ============================================================================= */ + +.site-footer { + margin: 8px 16px; + padding: 8px 12px; + border-top: 1px solid var(--border); + color: #94a3b8; + font-size: 12px; + text-align: center; +} + +.site-footer a { + color: #cbd5e1; + text-decoration: underline; +} + +/* ============================================================================= + THEME PREVIEW FRAGMENT (themes/preview_fragment.html) + ============================================================================= */ + +/* Preview header */ +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.preview-header h3 { + margin: 0; + font-size: 16px; +} + +.preview-header .btn { + font-size: 12px; + line-height: 1; +} + +/* Preview controls */ +.preview-controls { + display: flex; + gap: 1rem; + align-items: center; + margin: 0.5rem 0 0.75rem; + font-size: 11px; +} + +.preview-controls label { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.preview-controls .help-icon { + opacity: 0.55; + font-size: 10px; + cursor: help; +} + +.preview-controls #preview-status { + opacity: 0.65; +} + +/* Preview rationale */ +.preview-rationale { + margin: 0.25rem 0 0.85rem; + font-size: 11px; + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 0.55rem 0.7rem; + border-radius: 8px; +} + +.preview-rationale summary { + cursor: pointer; + font-weight: 600; + letter-spacing: 0.05em; +} + +.preview-rationale-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} + +.preview-rationale-controls .btn { + font-size: 10px; + padding: 4px 8px; +} + +.preview-rationale-controls #hover-compact-indicator { + font-size: 10px; + opacity: 0.7; +} + +.preview-rationale ul { + margin: 0.5rem 0 0 0.9rem; + padding: 0; + list-style: disc; + line-height: 1.35; +} + +.preview-rationale li .detail { + opacity: 0.75; +} + +.preview-rationale li .instances { + opacity: 0.65; +} + +/* Two column layout */ +.preview-two-col { + display: grid; + grid-template-columns: 1fr 480px; + gap: 1.25rem; + align-items: start; + position: relative; +} + +.preview-col-divider { + position: absolute; + top: 0; + bottom: 0; + left: calc(100% - 480px - 0.75rem); + width: 1px; + background: var(--border); + opacity: 0.55; +} + +/* Section headers */ +.preview-section-header { + margin: 0.25rem 0 0.5rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.8; +} + +.preview-section-hr { + border: 0; + border-top: 1px solid var(--border); + margin: 0.35rem 0 0.6rem; +} + +/* Cards flow layout */ +.cards-flow { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Group separators */ +.group-separator { + flex-basis: 100%; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; + margin-top: 0.25rem; +} + +.group-separator.mt-larger { + margin-top: 0.5rem; +} + +/* Card sample */ +.card-sample { + width: 230px; +} + +.card-sample .thumb-wrap { + position: relative; +} + +.card-sample img.card-thumb { + filter: blur(4px); + transition: filter 0.35s ease; + background: linear-gradient(145deg, #0b0d12, #111b29); +} + +.card-sample img.card-thumb[data-loaded] { + filter: blur(0); +} + +/* Card badges */ +.dup-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: #4b5563; + color: #fff; + font-size: 10px; + padding: 2px 5px; + border-radius: 10px; +} + +.pin-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 10px; + padding: 2px 5px; + cursor: pointer; +} + +/* Card metadata */ +.card-sample .meta { + font-size: 12px; + margin-top: 2px; +} + +.card-sample .ci-ribbon { + display: flex; + gap: 2px; + margin-bottom: 2px; + min-height: 10px; +} + +.card-sample .nm { + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-sample .mana-line { + min-height: 14px; + display: flex; + flex-wrap: wrap; + gap: 2px; + font-size: 10px; +} + +.card-sample .rarity-badge { + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.7; +} + +.card-sample .role { + opacity: 0.75; + font-size: 11px; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.card-sample .reasons { + font-size: 9px; + opacity: 0.55; + line-height: 1.15; +} + +/* Synthetic card */ +.card-sample.synthetic { + border: 1px dashed var(--border); + padding: 8px; + border-radius: 10px; + background: var(--panel-alt); +} + +.card-sample.synthetic .name { + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.card-sample.synthetic .roles { + font-size: 11px; + opacity: 0.8; +} + +.card-sample.synthetic .reasons-text { + font-size: 10px; + margin-top: 2px; + opacity: 0.6; + line-height: 1.15; +} + +/* Spacer */ +.full-width-spacer { + flex-basis: 100%; + height: 0; +} + +/* Commander grid */ +.commander-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1rem; +} + +.commander-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: center; +} + +.commander-name { + font-size: 13px; + text-align: center; + line-height: 1.35; + font-weight: 600; + max-width: 230px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commander-cell.synergy .commander-name { + font-size: 12px; + line-height: 1.3; + font-weight: 500; + opacity: 0.92; +} + +/* Synergy commanders section */ +.synergy-commanders-section { + margin-top: 1rem; +} + +.synergy-commanders-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} + +.synergy-commanders-header h5 { + margin: 0; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.75; +} + +.derived-badge { + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + line-height: 1; +} + +/* No commanders message */ +.no-commanders-message { + font-size: 11px; + opacity: 0.7; +} + +/* Footer help text */ +.preview-help-text { + margin-top: 1rem; + font-size: 10px; + opacity: 0.65; + line-height: 1.4; +} + +/* Skeleton loader */ +.preview-skeleton .sk-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-skeleton .sk-bar { + height: 16px; + background: var(--hover); + border-radius: 4px; +} + +.preview-skeleton .sk-bar.title { + width: 200px; +} + +.preview-skeleton .sk-bar.close { + width: 60px; +} + +.preview-skeleton .sk-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 1rem; +} + +.preview-skeleton .sk-card { + width: 230px; + height: 327px; + background: var(--hover); + border-radius: 10px; +} + +/* Responsive */ +@media (max-width: 950px) { + .preview-two-col { + grid-template-columns: 1fr; + } + + .preview-two-col .col-right { + order: -1; + } +} + +footer.site-footer { + flex-shrink: 0; +} + diff --git a/code/web/static/ts/.gitkeep b/code/web/static/ts/.gitkeep new file mode 100644 index 0000000..badfa20 --- /dev/null +++ b/code/web/static/ts/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder for TypeScript source files +# TypeScript files will be compiled to code/web/static/js/ diff --git a/code/web/static/ts/app.ts b/code/web/static/ts/app.ts new file mode 100644 index 0000000..3e276eb --- /dev/null +++ b/code/web/static/ts/app.ts @@ -0,0 +1,1702 @@ +/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */ +// Type definitions moved inline to avoid module system +interface StateManager { + get(key: string, def?: any): any; + set(key: string, val: any): void; + inHash(obj: Record): void; + readHash(): URLSearchParams; +} + +interface ToastOptions { + duration?: number; +} + +interface TelemetryManager { + send(eventName: string, data?: Record): void; +} + +interface SkeletonManager { + show(context?: HTMLElement | Document): void; + hide(context?: HTMLElement | Document): void; +} + +(function(){ + // Design tokens fallback (in case CSS variables missing in older browsers) + // No-op here since styles.css defines variables; kept for future JS reads. + + // State persistence helpers (localStorage + URL hash) + const state: StateManager = { + get: function(key: string, def?: any): any { + try { const v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; } + }, + set: function(key: string, val: any): void { + try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){} + }, + inHash: function(obj: Record): void { + // Merge obj into location.hash as query-like params + try { + const params = new URLSearchParams((location.hash||'').replace(/^#/, '')); + Object.keys(obj||{}).forEach(function(k: string){ params.set(k, obj[k]); }); + location.hash = params.toString(); + } catch(e){} + }, + readHash: function(): URLSearchParams { + try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); } + } + }; + window.__mtgState = state; + + // Toast system + let toastHost: HTMLElement | null = null; + function ensureToastHost(): HTMLElement { + if (!toastHost){ + toastHost = document.createElement('div'); + toastHost.className = 'toast-host'; + document.body.appendChild(toastHost); + } + return toastHost; + } + function toast(msg: string | HTMLElement, type?: string, opts?: ToastOptions): HTMLElement { + ensureToastHost(); + const t = document.createElement('div'); + t.className = 'toast' + (type ? ' '+type : ''); + t.setAttribute('role','status'); + t.setAttribute('aria-live','polite'); + t.textContent = ''; + if (typeof msg === 'string') { t.textContent = msg; } + else if (msg && msg.nodeType === 1) { t.appendChild(msg); } + toastHost!.appendChild(t); + const delay = (opts && opts.duration) || 2600; + setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay); + return t; + } + window.toast = toast; + function toastHTML(html: string, type?: string, opts?: ToastOptions): HTMLElement { + const container = document.createElement('div'); + container.innerHTML = html; + return toast(container, type, opts); + } + window.toastHTML = toastHTML; + + const telemetryEndpoint: string = (function(): string { + if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){ + return window.__telemetryEndpoint.trim(); + } + return '/telemetry/events'; + })(); + const telemetry: TelemetryManager = { + send: function(eventName: string, data?: Record): void { + if (!telemetryEndpoint || !eventName) return; + let payload: string; + try { + payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() }); + } catch(_){ return; } + try { + if (navigator.sendBeacon){ + const blob = new Blob([payload], { type: 'application/json' }); + navigator.sendBeacon(telemetryEndpoint, blob); + } else if (window.fetch){ + fetch(telemetryEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(function(){ /* noop */ }); + } + } catch(_){ } + } + }; + window.appTelemetry = telemetry; + + // Global HTMX error handling => toast + document.addEventListener('htmx:responseError', function(e){ + const detail = e.detail || {} as any; + const xhr = detail.xhr || {} as any; + const rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || ''; + const payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })() as any; + const status = payload.status || xhr.status || ''; + const msg = payload.detail || payload.message || 'Action failed'; + const path = payload.path || (e && e.detail && e.detail.path) || ''; + const html = ''+ + '
'+ + ''+String(msg)+''+ (status? ' ('+status+')' : '')+ + (rid ? '' : '')+ + '
'+ + (rid ? '
Request-ID: '+rid+'
' : ''); + const t = toastHTML(html, 'error', { duration: 7000 }); + // Wire Copy + const btn = t.querySelector('[data-copy-error]') as HTMLButtonElement; + if (btn){ + btn.addEventListener('click', function(){ + const lines = [ + 'Error: '+String(msg), + 'Status: '+String(status), + 'Path: '+String(path || (xhr.responseURL||'')), + 'Request-ID: '+String(rid) + ]; + try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ } + }); + } + // Optional inline banner if a surface is available + try { + const target = e && e.target as HTMLElement; + const surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]'); + if (surface){ + const banner = document.createElement('div'); + banner.className = 'inline-error-banner'; + banner.innerHTML = ''+String(msg)+'' + (rid? ' (Request-ID: '+rid+')' : ''); + surface.prepend(banner); + setTimeout(function(){ banner.remove(); }, 8000); + } + } catch(_){ } + }); + document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); }); + + // Keyboard shortcuts + const keymap: Record void> = { + ' ': function(){ const el = document.querySelector('[data-action="continue"], .btn-continue') as HTMLElement; if (el) el.click(); }, + 'r': function(){ const el = document.querySelector('[data-action="rerun"], .btn-rerun') as HTMLElement; if (el) el.click(); }, + 'b': function(){ const el = document.querySelector('[data-action="back"], .btn-back') as HTMLElement; if (el) el.click(); }, + 'l': function(){ const el = document.querySelector('[data-action="toggle-logs"], .btn-logs') as HTMLElement; if (el) el.click(); }, + }; + document.addEventListener('keydown', function(e){ + const target = e.target as HTMLElement; + if (target && (/input|textarea|select/i).test(target.tagName)) return; // don't hijack inputs + const k = e.key.toLowerCase(); + // If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock) + try { + const active = document.activeElement as HTMLElement; + if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) { + return; + } + } catch(_) { /* noop */ } + if (keymap[k]){ e.preventDefault(); keymap[k](); } + }); + + // Focus ring visibility for keyboard nav + function addFocusVisible(){ + let hadKeyboardEvent = false; + function onKeyDown(){ hadKeyboardEvent = true; } + function onPointer(){ hadKeyboardEvent = false; } + function onFocus(e: FocusEvent){ if (hadKeyboardEvent) (e.target as HTMLElement).classList.add('focus-visible'); } + function onBlur(e: FocusEvent){ (e.target as HTMLElement).classList.remove('focus-visible'); } + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('mousedown', onPointer, true); + window.addEventListener('pointerdown', onPointer, true); + window.addEventListener('touchstart', onPointer, true); + document.addEventListener('focusin', onFocus); + document.addEventListener('focusout', onBlur); + } + addFocusVisible(); + + // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable + let SKELETON_DELAY_DEFAULT = 400; + let skeletonTimers = new WeakMap(); + function gatherSkeletons(root){ + if (!root){ return []; } + let list = []; + let scope = (root.nodeType === 9) ? root.documentElement : root; + if (scope && scope.matches && scope.hasAttribute('data-skeleton')){ + list.push(scope); + } + if (scope && scope.querySelectorAll){ + scope.querySelectorAll('[data-skeleton]').forEach(function(el){ + if (list.indexOf(el) === -1){ list.push(el); } + }); + } + return list; + } + function scheduleSkeleton(el){ + let delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10); + let delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr); + clearSkeleton(el, false); + const timer = setTimeout(function(){ + el.classList.add('is-loading'); + el.setAttribute('aria-busy', 'true'); + skeletonTimers.set(el, null); + }, delay); + skeletonTimers.set(el, timer); + } + function clearSkeleton(el: HTMLElement, removeBusy?: boolean): void { + let timer = skeletonTimers.get(el); + if (typeof timer === 'number'){ + clearTimeout(timer); + } + skeletonTimers.delete(el); + el.classList.remove('is-loading'); + if (removeBusy !== false){ el.removeAttribute('aria-busy'); } + } + function showSkeletons(context?: HTMLElement | Document): void { + gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); }); + } + function hideSkeletons(context?: HTMLElement | Document): void { + gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el, true); }); + } + window.skeletons = { show: showSkeletons, hide: hideSkeletons }; + + document.addEventListener('htmx:beforeRequest', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + showSkeletons(target); + }); + document.addEventListener('htmx:afterSwap', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + document.addEventListener('htmx:afterRequest', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + + // Commander catalog image lazy loader + (function(){ + let PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + let observer = null; + let supportsIO = 'IntersectionObserver' in window; + + function ensureObserver(){ + if (observer || !supportsIO) return observer; + observer = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + if (entry.isIntersecting || entry.intersectionRatio > 0){ + let img = entry.target; + load(img); + if (observer) observer.unobserve(img); + } + }); + }, { rootMargin: '160px 0px', threshold: 0.05 }); + return observer; + } + + function load(img){ + if (!img || img.__lazyLoaded) return; + let src = img.getAttribute('data-lazy-src'); + if (src){ img.setAttribute('src', src); } + let srcset = img.getAttribute('data-lazy-srcset'); + if (srcset){ img.setAttribute('srcset', srcset); } + let sizes = img.getAttribute('data-lazy-sizes'); + if (sizes){ img.setAttribute('sizes', sizes); } + img.classList.remove('is-placeholder'); + img.removeAttribute('data-lazy-image'); + img.removeAttribute('data-lazy-src'); + img.removeAttribute('data-lazy-srcset'); + img.removeAttribute('data-lazy-sizes'); + img.__lazyLoaded = true; + } + + function prime(img){ + if (!img || img.__lazyPrimed) return; + let desired = img.getAttribute('data-lazy-src'); + if (!desired) return; + img.__lazyPrimed = true; + let placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL; + img.setAttribute('loading', 'lazy'); + img.setAttribute('decoding', 'async'); + img.classList.add('is-placeholder'); + img.removeAttribute('srcset'); + img.removeAttribute('sizes'); + img.setAttribute('src', placeholder); + if (supportsIO){ + ensureObserver().observe(img); + } else { + const loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); }; + loader(function(){ load(img); }); + } + } + + function collect(scope){ + if (!scope) scope = document; + if (scope === document){ + return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]')); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){ + return [scope]; + } + if (scope.querySelectorAll){ + return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]')); + } + return []; + } + + function process(scope){ + collect(scope).forEach(function(img){ + if (img.__lazyLoaded) return; + prime(img); + }); + } + + if (document.readyState === 'loading'){ + document.addEventListener('DOMContentLoaded', function(){ process(document); }); + } else { + process(document); + } + + document.addEventListener('htmx:afterSwap', function(evt){ + let target = evt && evt.detail ? evt.detail.target : null; + process(target || document); + }); + })(); + + const htmxCache = (function(){ + let store = new Map(); + function ttlFor(elt){ + let raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10); + if (isNaN(raw) || raw <= 0){ return 30000; } + return Math.max(1000, raw); + } + function buildKey(detail, elt){ + if (!detail) detail = {}; + if (elt && elt.getAttribute){ + let explicit = elt.getAttribute('data-hx-cache-key'); + if (explicit && explicit.trim()){ return explicit.trim(); } + } + let verb = (detail.verb || 'GET').toUpperCase(); + let path = detail.path || ''; + let params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : ''; + return verb + ' ' + path + ' ' + params; + } + function set(key, html, ttl, meta){ + if (!key || typeof html !== 'string') return; + store.set(key, { + key: key, + html: html, + expires: Date.now() + (ttl || 30000), + meta: meta || {}, + }); + } + function get(key){ + if (!key) return null; + let entry = store.get(key); + if (!entry) return null; + if (entry.expires && entry.expires <= Date.now()){ + store.delete(key); + return null; + } + return entry; + } + function applyCached(elt, detail, entry){ + if (!entry) return; + let target = detail && detail.target ? detail.target : elt; + if (!target) return; + dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + let swapSpec = ''; + try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ } + swapSpec = (swapSpec || 'innerHTML').toLowerCase(); + if (swapSpec.indexOf('outer') === 0){ + if (target.outerHTML !== undefined){ + target.outerHTML = entry.html; + } + } else if (target.innerHTML !== undefined){ + target.innerHTML = entry.html; + } + if (window.htmx && typeof window.htmx.process === 'function'){ + window.htmx.process(target); + } + dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + } + function prefetch(url, opts){ + if (!url) return; + opts = opts || {}; + let key = opts.key || ('GET ' + url); + if (get(key)) return; + try { + fetch(url, { + headers: { 'HX-Request': 'true', 'Accept': 'text/html' }, + cache: 'no-store', + }).then(function(resp){ + if (!resp.ok) throw new Error('prefetch failed'); + return resp.text(); + }).then(function(html){ + set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true }); + telemetry.send('htmx.cache.prefetch', { key: key, url: url }); + }).catch(function(){ /* noop */ }); + } catch(_){ } + } + return { + set: set, + get: get, + apply: applyCached, + buildKey: buildKey, + ttlFor: ttlFor, + prefetch: prefetch, + }; + })(); + window.htmxCache = htmxCache; + + document.addEventListener('htmx:configRequest', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return; + const verb = (detail.verb || 'GET').toUpperCase(); + if (verb !== 'GET') return; + const key = htmxCache.buildKey(detail, elt); + elt.__hxCacheKey = key; + elt.__hxCacheTTL = htmxCache.ttlFor(elt); + detail.headers = detail.headers || {}; + try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ } + }); + + document.addEventListener('htmx:beforeRequest', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.__hxCacheKey) return; + const entry = htmxCache.get(elt.__hxCacheKey); + if (entry){ + telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' }); + e.preventDefault(); + htmxCache.apply(elt, detail, entry); + } else { + telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' }); + } + }); + + document.addEventListener('htmx:afterSwap', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.__hxCacheKey) return; + try { + const xhr = detail.xhr; + const status = xhr && xhr.status ? xhr.status : 0; + if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){ + const ttl = elt.__hxCacheTTL || 30000; + htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' }); + telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl }); + } + } catch(_){ } + elt.__hxCacheKey = undefined; + elt.__hxCacheTTL = undefined; + }); + + (function(){ + function handlePrefetch(evt: Event){ + try { + const el = (evt.target as HTMLElement)?.closest ? (evt.target as HTMLElement).closest('[data-hx-prefetch]') : null; + if (!el || el.__hxPrefetched) return; + let url = el.getAttribute('data-hx-prefetch'); + if (!url) return; + el.__hxPrefetched = true; + let key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url); + let ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10); + let ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr); + htmxCache.prefetch(url, { key: key, ttl: ttl }); + } catch(_){ } + } + document.addEventListener('pointerenter', handlePrefetch, true); + document.addEventListener('focusin', handlePrefetch, true); + })(); + + // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce) + let hxDebounceGroups = new Map(); + function dispatchHtmx(el, evtName, detail){ + if (!el) return; + if (window.htmx && typeof window.htmx.trigger === 'function'){ + window.htmx.trigger(el, evtName, detail); + } else { + try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ } + } + } + function bindHtmxDebounce(el){ + if (!el || el.__hxDebounceBound) return; + el.__hxDebounceBound = true; + let delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10); + let delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw); + let eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input'; + let events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean); + if (!events.length){ events = ['input']; } + let trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput'; + let group = el.getAttribute('data-hx-debounce-group') || ''; + let flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase(); + let flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true'); + function clearTimer(){ + if (el.__hxDebounceTimer){ + clearTimeout(el.__hxDebounceTimer); + el.__hxDebounceTimer = null; + } + } + function schedule(){ + clearTimer(); + if (group){ + let prev = hxDebounceGroups.get(group); + if (prev && prev !== el && prev.__hxDebounceTimer){ + clearTimeout(prev.__hxDebounceTimer); + prev.__hxDebounceTimer = null; + } + hxDebounceGroups.set(group, el); + } + el.__hxDebounceTimer = setTimeout(function(){ + el.__hxDebounceTimer = null; + dispatchHtmx(el, trigger, {}); + }, delay); + } + events.forEach(function(evt){ + el.addEventListener(evt, schedule, { passive: true }); + }); + if (flushOnBlur){ + el.addEventListener('blur', function(){ + if (el.__hxDebounceTimer){ + clearTimer(); + dispatchHtmx(el, trigger, {}); + } + }); + } + el.addEventListener('htmx:beforeRequest', clearTimer); + } + function initHtmxDebounce(root){ + let scope = root || document; + if (scope === document){ scope = document.body || document; } + if (!scope) return; + let seen = new Set(); + function collect(candidate){ + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + bindHtmxDebounce(candidate); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){ + collect(scope); + } + if (scope.querySelectorAll){ + scope.querySelectorAll('[data-hx-debounce]').forEach(collect); + } + } + window.initHtmxDebounce = () => initHtmxDebounce(document.body); + + // Example: persist "show skipped" toggle if present + document.addEventListener('change', function(e){ + const el = e.target as HTMLInputElement; + if (el && el.matches('[data-pref]')){ + let key = el.getAttribute('data-pref'); + let val = (el.type === 'checkbox') ? !!el.checked : el.value; + state.set(key, val); + state.inHash((function(o){ o[key] = val; return o; })({})); + } + }); + // On load, initialize any data-pref elements + document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('[data-pref]').forEach(function(el){ + let key = el.getAttribute('data-pref'); + let saved = state.get(key, undefined); + if (typeof saved !== 'undefined'){ + if ((el as HTMLInputElement).type === 'checkbox') (el as HTMLInputElement).checked = !!saved; else (el as HTMLInputElement).value = saved; + } + }); + hydrateProgress(document); + syncShowSkipped(document); + initCardFilters(document); + initVirtualization(document); + initHtmxDebounce(document); + initMustHaveControls(document); + }); + + // Hydrate progress bars with width based on data-pct + function hydrateProgress(root){ + (root || document).querySelectorAll('.progress[data-pct]') + .forEach(function(p){ + let pct = parseInt(p.getAttribute('data-pct') || '0', 10); + if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100; + let bar = p.querySelector('.bar'); if (!bar) return; + // Animate width for a bit of delight + requestAnimationFrame(function(){ bar.style.width = pct + '%'; }); + }); + } + // Keep hidden inputs for show_skipped in sync with the sticky checkbox + function syncShowSkipped(root){ + let cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]'); + if (!cb) return; + let val = cb.checked ? '1' : '0'; + (root || document).querySelectorAll('section form').forEach(function(f){ + let h = f.querySelector('input[name="show_skipped"]'); + if (h) h.value = val; + }); + } + document.addEventListener('htmx:afterSwap', function(e){ + hydrateProgress(e.target as HTMLElement); + syncShowSkipped(e.target as HTMLElement); + initCardFilters(e.target as HTMLElement); + initVirtualization(e.target as HTMLElement); + initHtmxDebounce(e.target as HTMLElement); + initMustHaveControls(e.target as HTMLElement); + }); + + // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) + function scrollCardIntoView(name){ + if (!name) return; + try{ + let section = document.querySelector('section'); + let grid = section && section.querySelector('.card-grid'); + if (!grid) return; + // If virtualized, force a render around the approximate match by searching stored children + let target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + if (!target) { + // Trigger a render update and try again + grid.dispatchEvent(new Event('scroll')); // noop but can refresh + target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + } + if (target) { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + (target as HTMLElement).focus && (target as HTMLElement).focus(); + } + }catch(_){} + } + window.scrollCardIntoView = scrollCardIntoView; + + // --- Card grid filters, reasons, and collapsible groups --- + function initCardFilters(root){ + let section = (root || document).querySelector('section'); + if (!section) return; + let toolbar = section.querySelector('.cards-toolbar'); + if (!toolbar) return; // nothing to do + let q = toolbar.querySelector('input[name="filter_query"]'); + let ownedSel = toolbar.querySelector('select[name="filter_owned"]'); + let showReasons = toolbar.querySelector('input[name="show_reasons"]'); + let collapseGroups = toolbar.querySelector('input[name="collapse_groups"]'); + let resultsEl = toolbar.querySelector('[data-results]'); + let emptyEl = section.querySelector('[data-empty]'); + let sortSel = toolbar.querySelector('select[name="filter_sort"]'); + let chipOwned = toolbar.querySelector('[data-chip-owned="owned"]'); + let chipNot = toolbar.querySelector('[data-chip-owned="not"]'); + let chipAll = toolbar.querySelector('[data-chip-owned="all"]'); + let chipClear = toolbar.querySelector('[data-chip-clear]'); + + function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; } + // Read URL hash on first init to hydrate controls + try { + let params = window.__mtgState.readHash(); + if (params){ + let hv = params.get('q'); if (q && hv !== null) q.value = hv; + hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv; + hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1'); + hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1'); + hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv; + } + } catch(_){} + function apply(){ + let query = (getVal(q)+ '').toLowerCase().trim(); + let ownedMode = (getVal(ownedSel) || 'all'); + let showR = !!getVal(showReasons); + let collapse = !!getVal(collapseGroups); + let sortMode = (getVal(sortSel) || 'az'); + // Toggle reasons visibility via section class + section.classList.toggle('hide-reasons', !showR); + // Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state + section.querySelectorAll('.group').forEach(function(wrapper){ + let grid = wrapper.querySelector('.group-grid'); if (!grid) return; + let key = wrapper.getAttribute('data-group-key'); + if (collapse){ + grid.setAttribute('data-collapsed','1'); + } else { + // restore stored + if (key){ + let stored = state.get('cards:group:'+key, null); + if (stored === true){ grid.setAttribute('data-collapsed','1'); } + else { grid.removeAttribute('data-collapsed'); } + } else { + grid.removeAttribute('data-collapsed'); + } + } + }); + // Filter tiles + let tiles = section.querySelectorAll('.card-grid .card-tile'); + let visible = 0; + tiles.forEach(function(tile){ + let name = (tile.getAttribute('data-card-name')||'').toLowerCase(); + let role = (tile.getAttribute('data-role')||'').toLowerCase(); + let tags = (tile.getAttribute('data-tags')||'').toLowerCase(); + let tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase(); + let owned = tile.getAttribute('data-owned') === '1'; + let text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug; + let qOk = !query || text.indexOf(query) !== -1; + let oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned); + let show = qOk && oOk; + tile.style.display = show ? '' : 'none'; + if (show) visible++; + }); + // Sort within each grid + function keyFor(tile){ + let name = (tile.getAttribute('data-card-name')||''); + let owned = tile.getAttribute('data-owned') === '1' ? 1 : 0; + let gc = tile.classList.contains('game-changer') ? 1 : 0; + return { name: name.toLowerCase(), owned: owned, gc: gc }; + } + section.querySelectorAll('.card-grid').forEach(function(grid){ + const arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); + arr.sort(function(a,b){ + let ka = keyFor(a), kb = keyFor(b); + if (sortMode === 'owned'){ + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next + return ka.name.localeCompare(kb.name); + } else if (sortMode === 'gc'){ + if (kb.gc !== ka.gc) return kb.gc - ka.gc; + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + return ka.name.localeCompare(kb.name); + } + // default A–Z + return ka.name.localeCompare(kb.name); + }); + arr.forEach(function(el){ grid.appendChild(el); }); + }); + // Update group counts based on visible tiles within each group + section.querySelectorAll('.group').forEach(function(wrapper){ + let grid = wrapper.querySelector('.group-grid'); + let count = 0; + if (grid){ + grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; }); + } + let cEl = wrapper.querySelector('[data-count]'); + if (cEl) cEl.textContent = count; + }); + if (resultsEl) resultsEl.textContent = String(visible); + if (emptyEl) emptyEl.hidden = (visible !== 0); + // Persist prefs + if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value); + if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value); + if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked); + if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked); + if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value); + // Update URL hash for shareability + try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ } + } + // Wire events + if (q) q.addEventListener('input', apply); + if (ownedSel) ownedSel.addEventListener('change', apply); + if (showReasons) showReasons.addEventListener('change', apply); + if (collapseGroups) collapseGroups.addEventListener('change', apply); + if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); }); + if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); }); + if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); }); + if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); }); + // Individual group toggles + section.querySelectorAll('.group-header .toggle').forEach(function(btn){ + btn.addEventListener('click', function(){ + let wrapper = btn.closest('.group'); + let grid = wrapper && wrapper.querySelector('.group-grid'); + if (!grid) return; + let key = wrapper.getAttribute('data-group-key'); + let willCollapse = !grid.getAttribute('data-collapsed'); + if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed'); + if (key){ state.set('cards:group:'+key, !!willCollapse); } + // ARIA + btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true'); + }); + }); + // Per-card reason toggle: delegate clicks on .btn-why + section.addEventListener('click', function(e){ + let t = e.target; + if (!t || !t.classList || !t.classList.contains('btn-why')) return; + e.preventDefault(); + let tile = t.closest('.card-tile'); + if (!tile) return; + let globalHidden = section.classList.contains('hide-reasons'); + if (globalHidden){ + // Force-show overrides global hidden + let on = tile.classList.toggle('force-show'); + if (on) tile.classList.remove('force-hide'); + t.textContent = on ? 'Hide why' : 'Why?'; + } else { + // Hide this tile only + let off = tile.classList.toggle('force-hide'); + if (off) tile.classList.remove('force-show'); + t.textContent = off ? 'Show why' : 'Hide why'; + } + }); + // Initial apply on hydrate + apply(); + + // Keyboard helpers: '/' focuses query, Esc clears + function onKey(e){ + // avoid when typing in inputs + if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; + if (e.key === '/'){ + if (q){ e.preventDefault(); q.focus(); q.select && q.select(); } + } else if (e.key === 'Escape'){ + if (q && q.value){ q.value=''; apply(); } + } + } + document.addEventListener('keydown', onKey); + } + + // --- Lightweight virtualization (feature-flagged via data-virtualize) --- + function initVirtualization(root){ + try{ + let body = document.body || document.documentElement; + const DIAG = !!(body && body.getAttribute('data-diag') === '1'); + const GLOBAL = (function(){ + if (!DIAG) return null; + if (window.__virtGlobal) return window.__virtGlobal; + let store = { grids: [], summaryEl: null }; + function ensure(){ + if (!store.summaryEl){ + let el = document.createElement('div'); + el.id = 'virt-global-diag'; + el.style.position = 'fixed'; + el.style.right = '8px'; + el.style.bottom = '8px'; + el.style.background = 'rgba(17,24,39,.85)'; + el.style.border = '1px solid var(--border)'; + el.style.padding = '.25rem .5rem'; + el.style.borderRadius = '6px'; + el.style.fontSize = '12px'; + el.style.color = '#cbd5e1'; + el.style.zIndex = '50'; + el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)'; + el.style.cursor = 'default'; + el.style.display = 'none'; + document.body.appendChild(el); + store.summaryEl = el; + } + return store.summaryEl; + } + function update(){ + let el = ensure(); if (!el) return; + let g = store.grids; + let total = 0, visible = 0, lastMs = 0; + for (let i=0;i -1 ? 110 : 240); + let minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow; + let rowH = minRowH; + let explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null; + let perRow = explicitCols || 1; + + let diagBox = null; let lastRenderAt = 0; let lastRenderMs = 0; + let renderCount = 0; let measureCount = 0; let swapCount = 0; + let gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6); + let globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null; + + function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } } + function ensureDiag(){ + if (!DIAG) return null; + if (diagBox) return diagBox; + diagBox = document.createElement('div'); + diagBox.className = 'virt-diag'; + diagBox.style.position = 'sticky'; + diagBox.style.top = '0'; + diagBox.style.zIndex = '5'; + diagBox.style.background = 'rgba(17,24,39,.85)'; + diagBox.style.border = '1px solid var(--border)'; + diagBox.style.padding = '.25rem .5rem'; + diagBox.style.borderRadius = '6px'; + diagBox.style.fontSize = '12px'; + diagBox.style.margin = '0 0 .35rem 0'; + diagBox.style.color = '#cbd5e1'; + diagBox.style.display = 'none'; + let controls = document.createElement('div'); + controls.style.display = 'flex'; + controls.style.gap = '.35rem'; + controls.style.alignItems = 'center'; + controls.style.marginBottom = '.25rem'; + let title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af'; + let btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small'; + btnCopy.addEventListener('click', function(){ + try{ + let payload = { + id: gridId, + rowH: rowH, + perRow: perRow, + start: start, + end: end, + total: total, + renderCount: renderCount, + measureCount: measureCount, + swapCount: swapCount, + lastRenderMs: lastRenderMs, + lastRenderAt: lastRenderAt, + }; + navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + btnCopy.textContent = 'Copied'; + setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); + }catch(_){ } + }); + let btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small'; + btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; }); + controls.appendChild(title); + controls.appendChild(btnCopy); + controls.appendChild(btnHide); + diagBox.appendChild(controls); + let text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text); + let host = (container.id === 'owned-box') ? container : container.parentElement || container; + host.insertBefore(diagBox, host.firstChild); + return diagBox; + } + + function measure(){ + try { + measureCount++; + let probe = store.firstElementChild || all[0]; + if (probe){ + let fake = probe.cloneNode(true); + fake.style.position = 'absolute'; + fake.style.visibility = 'hidden'; + fake.style.pointerEvents = 'none'; + (ownedGrid || container).appendChild(fake); + let rect = fake.getBoundingClientRect(); + rowH = Math.max(minRowH, Math.ceil(rect.height) + 16); + (ownedGrid || container).removeChild(fake); + } + let style = window.getComputedStyle(ownedGrid || container); + let cols = style.getPropertyValue('grid-template-columns'); + try { + let displayMode = style.getPropertyValue('display'); + if (displayMode && displayMode.trim()){ + wrapper.style.display = displayMode; + } else if (!wrapper.style.display){ + wrapper.style.display = 'grid'; + } + if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols; + let gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap'); + if (gap && gap.trim()) wrapper.style.gap = gap; + let ji = style.getPropertyValue('justify-items'); + if (ji && ji.trim()) wrapper.style.justifyItems = ji; + let ai = style.getPropertyValue('align-items'); + if (ai && ai.trim()) wrapper.style.alignItems = ai; + } catch(_){ } + const derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){ + return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1); + }).length : 0); + if (explicitCols){ + perRow = explicitCols; + } else if (derivedCols){ + perRow = Math.max(1, derivedCols); + } else { + perRow = Math.max(1, perRow); + } + } catch(_){ } + } + + measure(); + let total = all.length; + let start = 0, end = 0; + + function render(){ + let t0 = DIAG ? performance.now() : 0; + let scroller = container; + let vh, scrollTop, top; + + if (useWindowScroll) { + // Window-scroll mode: measure relative to viewport + vh = window.innerHeight; + let rect = container.getBoundingClientRect(); + top = Math.max(0, -rect.top); + scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + } else { + // Container-scroll mode: measure relative to container + vh = scroller.clientHeight || window.innerHeight; + scrollTop = scroller.scrollTop; + top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); + } + + let rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2; + let rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1); + let rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow))); + let newStart = rowStart * Math.max(1, perRow); + let newEnd = Math.min(total, rowEnd * Math.max(1, perRow)); + if (newStart === start && newEnd === end) return; + start = newStart; + end = newEnd; + let beforeRows = Math.floor(start / Math.max(1, perRow)); + let afterRows = Math.ceil((total - end) / Math.max(1, perRow)); + padTop.style.height = (beforeRows * rowH) + 'px'; + padBottom.style.height = (afterRows * rowH) + 'px'; + wrapper.innerHTML = ''; + for (let i = start; i < end; i++){ + let node = all[i]; + if (node) wrapper.appendChild(node); + } + if (DIAG){ + let box = ensureDiag(); + if (box){ + let dt = performance.now() - t0; + lastRenderMs = dt; + renderCount++; + lastRenderAt = Date.now(); + let vis = end - start; + let rowsTotal = Math.ceil(total / Math.max(1, perRow)); + let textEl = box.querySelector('.virt-diag-text'); + let msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount; + textEl.textContent = msg; + let bad = (dt > 33) || (vis > 300); + let warn = (!bad) && ((dt > 16) || (vis > 200)); + box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)'); + box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none'); + if (globalReg && globalReg.set){ + globalReg.set({ total: total, start: start, end: end, lastMs: dt }); + } + } + } + } + + function onScroll(){ render(); } + function onResize(){ measure(); render(); } + + // Support both container-scroll (default) and window-scroll modes + let scrollMode = overflowAttr || container.style.overflow || 'auto'; + let useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window'); + + if (useWindowScroll) { + // Window-scroll mode: listen to window scroll events + window.addEventListener('scroll', onScroll, { passive: true }); + } else { + // Container-scroll mode: listen to container scroll events + container.addEventListener('scroll', onScroll, { passive: true }); + } + window.addEventListener('resize', onResize); + + render(); + + // Track cleanup for disconnected containers + grid.__virtCleanup = function(){ + try { + if (useWindowScroll) { + window.removeEventListener('scroll', onScroll); + } else { + container.removeEventListener('scroll', onScroll); + } + window.removeEventListener('resize', onResize); + } catch(_){} + }; + + document.addEventListener('htmx:afterSwap', function(ev){ + if (!container.isConnected) return; + if (!container.contains(ev.target)) return; + swapCount++; + let merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); + const known = new Map(); + all.forEach(function(node, idx){ + let index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx; + known.set(node, index); + }); + let nextIndex = known.size; + merged.forEach(function(node){ + if (!known.has(node)){ + node.__virtIndex = nextIndex; + known.set(node, nextIndex); + nextIndex++; + } + }); + merged.sort(function(a, b){ + let ia = known.get(a); + const ib = known.get(b); + return (ia - ib); + }); + merged.forEach(function(node, idx){ node.__virtIndex = idx; }); + all = merged; + total = all.length; + measure(); + render(); + }); + + if (DIAG && !window.__virtHotkeyBound){ + window.__virtHotkeyBound = true; + document.addEventListener('keydown', function(e){ + try{ + if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; + if (e.key && e.key.toLowerCase() === 'v'){ + e.preventDefault(); + let shown = null; + document.querySelectorAll('.virt-diag').forEach(function(b){ + if (shown === null) shown = ((b as HTMLElement).style.display === 'none'); + (b as HTMLElement).style.display = shown ? '' : 'none'; + }); + if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle(); + } + }catch(_){ } + }); + } + }); + }catch(_){ } + } + + function setTileState(tile, type, active){ + if (!tile) return; + let attr = 'data-must-' + type; + tile.setAttribute(attr, active ? '1' : '0'); + tile.classList.toggle('must-' + type, !!active); + let selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude'); + try { + let btn = tile.querySelector(selector); + if (btn){ + btn.setAttribute('data-active', active ? '1' : '0'); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.classList.toggle('is-active', !!active); + } + } catch(_){ } + } + + function restoreMustHaveState(tile, state){ + if (!tile || !state) return; + setTileState(tile, 'include', state.include ? 1 : 0); + setTileState(tile, 'exclude', state.exclude ? 1 : 0); + } + + function applyLocalMustHave(tile, type, enabled){ + if (!tile) return; + if (type === 'include'){ + setTileState(tile, 'include', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'exclude', 0); } + } else if (type === 'exclude'){ + setTileState(tile, 'exclude', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'include', 0); } + } + } + + function sendMustHaveRequest(tile, type, enabled, cardName, prevState){ + if (!window.htmx){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Offline: cannot update preference', 'error', { duration: 4000 }); + return; + } + let summaryTarget = document.getElementById('include-exclude-summary'); + let ajaxOptions = { + source: tile, + target: summaryTarget || tile, + swap: summaryTarget ? 'outerHTML' : 'none', + values: { + card_name: cardName, + list_type: type, + enabled: enabled ? '1' : '0', + }, + }; + let xhr; + try { + xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions); + } catch(_){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Unable to submit preference update', 'error', { duration: 4500 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' }); + return; + } + if (!xhr || !xhr.addEventListener){ + tile.setAttribute('data-must-pending', '0'); + return; + } + xhr.addEventListener('load', function(evt){ + tile.setAttribute('data-must-pending', '0'); + let request = evt && evt.currentTarget ? evt.currentTarget : xhr; + let status = request.status || 0; + if (status >= 400){ + restoreMustHaveState(tile, prevState); + let msg = 'Failed to update preference'; + try { + let data = JSON.parse(request.responseText || '{}'); + if (data && data.error) msg = data.error; + } catch(_){ } + toast(msg, 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status }); + return; + } + let message; + if (enabled){ + message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude'; + } else { + message = (type === 'include') ? 'Removed must include' : 'Removed must exclude'; + } + toast(message + ': ' + cardName, 'success', { duration: 2400 }); + telemetry.send('must_have.toggle', { + card: cardName, + list: type, + enabled: enabled, + requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null, + }); + }); + xhr.addEventListener('error', function(){ + tile.setAttribute('data-must-pending', '0'); + restoreMustHaveState(tile, prevState); + toast('Network error updating preference', 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' }); + }); + } + + function initMustHaveControls(root){ + let scope = root && root.querySelectorAll ? root : document; + if (scope === document && document.body) scope = document.body; + if (!scope || !scope.querySelectorAll) return; + scope.querySelectorAll('.must-have-btn').forEach(function(btn){ + if (!btn || btn.__mustHaveBound) return; + btn.__mustHaveBound = true; + let active = btn.getAttribute('data-active') === '1'; + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.addEventListener('click', function(ev){ + ev.preventDefault(); + let tile = btn.closest('.card-tile'); + if (!tile) return; + if (tile.getAttribute('data-must-pending') === '1') return; + let type = btn.getAttribute('data-toggle'); + if (!type) return; + let prevState = { + include: tile.getAttribute('data-must-include') === '1', + exclude: tile.getAttribute('data-must-exclude') === '1', + }; + let nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude); + let label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || ''; + tile.setAttribute('data-must-pending', '1'); + applyLocalMustHave(tile, type, nextEnabled); + sendMustHaveRequest(tile, type, nextEnabled, label, prevState); + }); + }); + } + + // LQIP blur/fade-in for thumbnails marked with data-lqip + document.addEventListener('DOMContentLoaded', function(){ + try{ + document.querySelectorAll('img[data-lqip]') + .forEach(function(img){ + img.classList.add('lqip'); + img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true }); + }); + }catch(_){ } + }); + + // --- Lazy-loading analytics accordions --- + function initLazyAccordions(root){ + try { + let scope = root || document; + if (!scope || !scope.querySelectorAll) return; + + scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){ + if (!details || details.__lazyBound) return; + details.__lazyBound = true; + + let loaded = false; + + details.addEventListener('toggle', function(){ + if (!details.open || loaded) return; + loaded = true; + + // Mark as loaded to prevent re-initialization + let content = details.querySelector('.analytics-content'); + if (!content) return; + + // Remove placeholder if present + let placeholder = content.querySelector('.analytics-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Content is already rendered in the template, just need to initialize any scripts + // Re-run virtualization if needed + try { + initVirtualization(content); + } catch(_){} + + // Re-attach chart interactivity if this is mana overview + let type = details.getAttribute('data-analytics-type'); + if (type === 'mana') { + try { + // Tooltip and highlight logic is already in the template scripts + // Just trigger a synthetic event to re-attach if needed + let event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } }); + details.dispatchEvent(event); + } catch(_){} + } + + // Send telemetry + telemetry.send('analytics.accordion_expand', { + type: type || 'unknown', + accordion: details.id || 'unnamed', + }); + }); + }); + } catch(_){} + } + + // Initialize on load and after HTMX swaps + document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(document.body); }); + document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); }); + + // ============================================================================= + // UTILITIES EXTRACTED FROM BASE.HTML INLINE SCRIPTS (Phase 3) + // ============================================================================= + + /** + * Poll setup status endpoint for progress updates + * Shows dynamic status message in #banner-status element + */ + function initSetupStatusPoller(): void { + let statusEl: HTMLElement | null = null; + + function ensureStatusEl(): HTMLElement | null { + if (!statusEl) statusEl = document.getElementById('banner-status'); + return statusEl; + } + + function renderSetupStatus(data: any): void { + const el = ensureStatusEl(); + if (!el) return; + + if (data && data.running) { + const msg = data.message || 'Preparing data...'; + const pct = (typeof data.percent === 'number') ? data.percent : null; + + // Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed + let suppress = false; + if (pct !== null && pct >= 99) suppress = true; + const lm = (msg || '').toLowerCase(); + if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true; + + if (suppress) { + if (el.innerHTML) { + el.innerHTML = ''; + el.classList.remove('busy'); + } + return; + } + + el.innerHTML = 'Setup/Tagging: ' + msg + ' View progress'; + el.classList.add('busy'); + } else if (data && data.phase === 'done') { + el.innerHTML = ''; + el.classList.remove('busy'); + } else if (data && data.phase === 'error') { + el.innerHTML = 'Setup error.'; + setTimeout(function(){ + el.innerHTML = ''; + el.classList.remove('busy'); + }, 5000); + } else { + if (!el.innerHTML.trim()) el.innerHTML = ''; + el.classList.remove('busy'); + } + } + + function pollStatus(): void { + try { + fetch('/status/setup', { cache: 'no-store' }) + .then(function(r){ return r.json(); }) + .then(renderSetupStatus) + .catch(function(){ /* noop */ }); + } catch(_){} + } + + // Poll every 10 seconds to reduce server load (only for header indicator) + setInterval(pollStatus, 10000); + pollStatus(); // Initial poll + } + + /** + * Highlight active navigation link based on current path + * Matches exact or prefix paths, prioritizing longer matches + */ + function initActiveNavHighlighter(): void { + try { + const path = window.location.pathname || '/'; + const nav = document.getElementById('primary-nav'); + if (!nav) return; + + const links = nav.querySelectorAll('a'); + let best: HTMLAnchorElement | null = null; + let bestLen = -1; + + links.forEach(function(a){ + const href = a.getAttribute('href') || ''; + if (!href) return; + // Exact match or prefix match (ignoring trailing slash) + if (path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){ + if (href.length > bestLen){ + best = a as HTMLAnchorElement; + bestLen = href.length; + } + } + }); + + if (best) best.classList.add('active'); + } catch(_){} + } + + /** + * Initialize theme selector dropdown and persistence + * Handles localStorage, URL overrides, and system preference tracking + */ + function initThemeSelector(enableThemes: boolean, defaultTheme: string): void { + if (!enableThemes) return; + + try { + const sel = document.getElementById('theme-select') as HTMLSelectElement | null; + const resetBtn = document.getElementById('theme-reset'); + const root = document.documentElement; + const KEY = 'mtg:theme'; + const SERVER_DEFAULT = defaultTheme; + + function mapLight(v: string): string { + return v === 'light' ? 'light-blend' : v; + } + + function resolveSystem(): string { + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light-blend'; + } + + function normalizeUiValue(v: string): string { + const x = (v || 'system').toLowerCase(); + if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light'; + return x; + } + + function apply(val: string): void { + let v = (val || 'system').toLowerCase(); + if (v === 'system') v = resolveSystem(); + v = mapLight(v); + root.setAttribute('data-theme', v); + } + + // Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly + const params = new URLSearchParams(window.location.search || ''); + const urlTheme = (params.get('theme') || '').toLowerCase(); + if (urlTheme) { + // Persist the UI value, not the mapped CSS token + localStorage.setItem(KEY, normalizeUiValue(urlTheme)); + // Clean the URL so reloads don't keep overriding + try { + const u = new URL(window.location.href); + u.searchParams.delete('theme'); + window.history.replaceState({}, document.title, u.toString()); + } catch(_){} + } + + // Determine initial selection: URL -> localStorage -> server default -> system + const stored = localStorage.getItem(KEY); + const initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system')); + apply(initial); + + if (sel) { + sel.value = normalizeUiValue(initial); + sel.addEventListener('change', function(){ + const v = sel.value || 'system'; + localStorage.setItem(KEY, v); + apply(v); + }); + } + + if (resetBtn) { + resetBtn.addEventListener('click', function(){ + try { localStorage.removeItem(KEY); } catch(_){} + const v = SERVER_DEFAULT || 'system'; + apply(v); + if (sel) sel.value = normalizeUiValue(v); + }); + } + + // React to system changes when set to system + if (window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener && mq.addEventListener('change', function(){ + const cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system'); + if (cur === 'system') apply('system'); + }); + } + } catch(_){} + } + + /** + * Apply theme from environment variable when selector is disabled + * Resolves 'system' to OS preference + */ + function initThemeEnvOnly(enableThemes: boolean, defaultTheme: string): void { + if (enableThemes) return; // Only run when themes are disabled + + try { + const root = document.documentElement; + const SERVER_DEFAULT = defaultTheme; + + function resolveSystem(): string { + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light-blend'; + } + + let v = (SERVER_DEFAULT || 'system').toLowerCase(); + if (v === 'system') v = resolveSystem(); + if (v === 'light') v = 'light-blend'; + root.setAttribute('data-theme', v); + + // Track OS changes when using system + if ((SERVER_DEFAULT || 'system').toLowerCase() === 'system' && window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener && mq.addEventListener('change', function(){ + root.setAttribute('data-theme', resolveSystem()); + }); + } + } catch(_){} + } + + /** + * Register PWA service worker and handle updates + * Automatically reloads when new version is available + */ + function initServiceWorker(enablePwa: boolean, catalogHash: string): void { + if (!enablePwa) return; + + try { + if ('serviceWorker' in navigator) { + const ver = catalogHash || 'dev'; + const url = '/static/sw.js?v=' + encodeURIComponent(ver); + + navigator.serviceWorker.register(url).then(function(reg){ + (window as any).__pwaStatus = { registered: true, scope: reg.scope, version: ver }; + + // Listen for updates (new worker installing) + if (reg.waiting) { + reg.waiting.postMessage({ type: 'SKIP_WAITING' }); + } + + reg.addEventListener('updatefound', function(){ + try { + const nw = reg.installing; + if (!nw) return; + + nw.addEventListener('statechange', function(){ + if (nw.state === 'installed' && navigator.serviceWorker.controller) { + // New version available; reload silently for freshness + try { + sessionStorage.setItem('mtg:swUpdated', '1'); + } catch(_){} + window.location.reload(); + } + }); + } catch(_){} + }); + }).catch(function(){ + (window as any).__pwaStatus = { registered: false }; + }); + } + } catch(_){} + } + + /** + * Show toast after page reload + * Used when actions replace the whole document + */ + function initToastAfterReload(): void { + try { + const raw = sessionStorage.getItem('mtg:toastAfterReload'); + if (raw) { + sessionStorage.removeItem('mtg:toastAfterReload'); + const data = JSON.parse(raw); + if (data && data.msg) { + window.toast && window.toast(data.msg, data.type || ''); + } + } + } catch(_){} + } + + // Initialize all utilities on DOMContentLoaded + document.addEventListener('DOMContentLoaded', function(){ + initSetupStatusPoller(); + initActiveNavHighlighter(); + initToastAfterReload(); + + // Theme and PWA initialization require server-injected values + // These will be called from base.html inline scripts that pass the values + // window.__initThemeSelector, window.__initThemeEnvOnly, window.__initServiceWorker + }); + + // Expose functions globally for inline script calls (with server values) + (window as any).__initThemeSelector = initThemeSelector; + (window as any).__initThemeEnvOnly = initThemeEnvOnly; + (window as any).__initServiceWorker = initServiceWorker; +})(); diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts new file mode 100644 index 0000000..15f0836 --- /dev/null +++ b/code/web/static/ts/cardHover.ts @@ -0,0 +1,798 @@ +/** + * Card Hover Panel System + * + * Unified hover/tap card preview panel with mobile support. + * Displays card images with metadata (role, tags, themes, overlaps). + * + * Features: + * - Desktop: Hover to show, follows mouse pointer + * - Mobile: Tap to show, centered modal with close button + * - Keyboard accessible with focus/escape handling + * - Image prefetch LRU cache for performance + * - DFC (double-faced card) flip support + * - Tag overlap highlighting + * - Curated-only and reasons toggles for preview modals + * + * NOTE: This module exposes functions globally on window for browser compatibility + */ + +interface PointerEventLike { + clientX: number; + clientY: number; +} + +// Expose globally for browser usage (CommonJS exports don't work in browser without bundler) +(window as any).__initHoverCardPanel = function initHoverCardPanel(): void { + // Global delegated curated-only & reasons controls (works after HTMX swaps and inline render) + function findPreviewRoot(el: Element): Element | null { + return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); + } + + function applyCuratedFor(root: Element): void { + const checkbox = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; + const status = root.querySelector('#preview-status') as HTMLElement | null; + if (!checkbox) return; + + // Persist + try { + localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1' : '0'); + } catch (_) { } + + const curatedOnly = checkbox.checked; + let hidden = 0; + root.querySelectorAll('.card-sample').forEach((card) => { + const role = card.getAttribute('data-role'); + const isCurated = role === 'example' || role === 'curated_synergy' || role === 'synthetic'; + if (curatedOnly && !isCurated) { + (card as HTMLElement).style.display = 'none'; + hidden++; + } else { + (card as HTMLElement).style.display = ''; + } + }); + + if (status) status.textContent = curatedOnly ? (`Hid ${hidden} sampled cards`) : ''; + } + + function applyReasonsFor(root: Element): void { + const cb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; + if (!cb) return; + + try { + localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1' : '0'); + } catch (_) { } + + const show = cb.checked; + root.querySelectorAll('[data-reasons-block]').forEach((el) => { + (el as HTMLElement).style.display = show ? '' : 'none'; + }); + } + + document.addEventListener('change', (e) => { + if (e.target && (e.target as HTMLElement).id === 'curated-only-toggle') { + const root = findPreviewRoot(e.target as HTMLElement); + if (root) applyCuratedFor(root); + } + }); + + document.addEventListener('change', (e) => { + if (e.target && (e.target as HTMLElement).id === 'reasons-toggle') { + const root = findPreviewRoot(e.target as HTMLElement); + if (root) applyReasonsFor(root); + } + }); + + document.addEventListener('htmx:afterSwap', (ev: any) => { + const frag = ev.target; + if (frag && frag.querySelector) { + if (frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag); + if (frag.querySelector('#reasons-toggle')) applyReasonsFor(frag); + } + }); + + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.preview-modal-content').forEach((root) => { + // Restore persisted states before applying + try { + const cVal = localStorage.getItem('mtg:preview.curatedOnly'); + if (cVal !== null) { + const cb = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; + if (cb) cb.checked = cVal === '1'; + } + const rVal = localStorage.getItem('mtg:preview.showReasons'); + if (rVal !== null) { + const rb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; + if (rb) rb.checked = rVal === '1'; + } + } catch (_) { } + + if (root.querySelector('#curated-only-toggle')) applyCuratedFor(root); + if (root.querySelector('#reasons-toggle')) applyReasonsFor(root); + }); + }); + + function createPanel(): HTMLElement { + const panel = document.createElement('div'); + panel.id = 'hover-card-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', 'Card detail hover panel'); + panel.setAttribute('aria-hidden', 'true'); + panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:var(--text);font-size:14px;line-height:1.45;pointer-events:none;'; + panel.innerHTML = '' + + '
' + + '
 
' + + '
' + + '' + + '
' + + '
' + + '
' + + 'Card image' + + '
' + + '
' + + '
' + + '
 
' + + '
' + + '
' + + '
    ' + + '
    ' + + '
      ' + + '
      ' + + '
      ' + + '
      '; + document.body.appendChild(panel); + return panel; + } + + function ensurePanel(): HTMLElement { + let panel = document.getElementById('hover-card-panel'); + if (panel) return panel; + // Auto-create for direct theme pages where fragment-specific markup not injected + return createPanel(); + } + + function setup(): void { + const panel = ensurePanel(); + if (!panel || (panel as any).__hoverInit) return; + (panel as any).__hoverInit = true; + + const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement; + const nameEl = panel.querySelector('.hcp-name') as HTMLElement; + const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement; + const metaEl = panel.querySelector('.hcp-meta') as HTMLElement; + const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement; + const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement; + const bodyEl = panel.querySelector('.hcp-body') as HTMLElement; + const rightCol = panel.querySelector('.hcp-right') as HTMLElement; + + const coarseQuery = window.matchMedia('(pointer: coarse)'); + + function isMobileMode(): boolean { + return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; + } + + function refreshPosition(): void { + if (panel.style.display === 'block') { + move((window as any).__lastPointerEvent); + } + } + + if (coarseQuery) { + const handler = () => { refreshPosition(); }; + if (coarseQuery.addEventListener) { + coarseQuery.addEventListener('change', handler); + } else if ((coarseQuery as any).addListener) { + (coarseQuery as any).addListener(handler); + } + } + + window.addEventListener('resize', refreshPosition); + + const closeBtn = panel.querySelector('.hcp-close') as HTMLButtonElement; + if (closeBtn && !(closeBtn as any).__bound) { + (closeBtn as any).__bound = true; + closeBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + hide(); + }); + } + + function positionPanel(evt: PointerEventLike): void { + if (isMobileMode()) { + panel.classList.add('mobile'); + panel.style.bottom = 'auto'; + panel.style.left = '50%'; + panel.style.top = '50%'; + panel.style.right = 'auto'; + panel.style.transform = 'translate(-50%, -50%)'; + panel.style.pointerEvents = 'auto'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + const pad = 18; + let x = evt.clientX + pad, y = evt.clientY + pad; + const vw = window.innerWidth, vh = window.innerHeight; + const r = panel.getBoundingClientRect(); + if (x + r.width + 8 > vw) x = evt.clientX - r.width - pad; + if (y + r.height + 8 > vh) y = evt.clientY - r.height - pad; + if (x < 8) x = 8; + if (y < 8) y = 8; + panel.style.left = x + 'px'; + panel.style.top = y + 'px'; + panel.style.bottom = 'auto'; + panel.style.right = 'auto'; + } + } + + function move(evt?: PointerEventLike): void { + if (panel.style.display === 'none') return; + if (!evt) evt = (window as any).__lastPointerEvent; + if (!evt && lastCard) { + const rect = lastCard.getBoundingClientRect(); + evt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + } + if (!evt) evt = { clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 }; + positionPanel(evt); + } + + // Lightweight image prefetch LRU cache (size 12) + const imgLRU: string[] = []; + function prefetch(src: string): void { + if (!src) return; + if (imgLRU.indexOf(src) === -1) { + imgLRU.push(src); + if (imgLRU.length > 12) imgLRU.shift(); + const im = new Image(); + im.src = src; + } + } + + const activationDelay = 120; // ms + let hoverTimer: number | null = null; + + function schedule(card: Element, evt: PointerEventLike): void { + if (hoverTimer !== null) clearTimeout(hoverTimer); + hoverTimer = window.setTimeout(() => { show(card, evt); }, activationDelay); + } + + function cancelSchedule(): void { + if (hoverTimer !== null) { + clearTimeout(hoverTimer); + hoverTimer = null; + } + } + + let lastCard: Element | null = null; + + function show(card: Element, evt?: PointerEventLike): void { + if (!card) return; + + // Prefer attributes on container, fallback to child (image) if missing + function attr(name: string): string { + return card.getAttribute(name) || + (card.querySelector(`[data-${name.slice(5)}]`)?.getAttribute(name)) || ''; + } + + let simpleSource: Element | null = null; + if (card.closest) { + simpleSource = card.closest('[data-hover-simple]'); + } + + const forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource; + const nm = attr('data-card-name') || attr('data-original-name') || 'Card'; + const rarity = (attr('data-rarity') || '').trim(); + const mana = (attr('data-mana') || '').trim(); + const role = (attr('data-role') || '').trim(); + let reasonsRaw = attr('data-reasons') || ''; + const tagsRaw = attr('data-tags') || ''; + const metadataTagsRaw = attr('data-metadata-tags') || ''; + const roleEl = panel.querySelector('.hcp-role') as HTMLElement; + // Check for flip button on card or its parent container (for elements in commander browser) + let hasFlip = !!card.querySelector('.dfc-toggle'); + if (!hasFlip && card.parentElement) { + hasFlip = !!card.parentElement.querySelector('.dfc-toggle'); + } + const tagListEl = panel.querySelector('.hcp-taglist') as HTMLElement; + const overlapsEl = panel.querySelector('.hcp-overlaps') as HTMLElement; + const overlapsAttr = attr('data-overlaps') || ''; + + function displayLabel(text: string): string { + if (!text) return ''; + let label = String(text); + label = label.replace(/[\u2022\-_]+/g, ' '); + label = label.replace(/\s+/g, ' ').trim(); + return label; + } + + function parseTagList(raw: string): string[] { + if (!raw) return []; + const trimmed = String(raw).trim(); + if (!trimmed) return []; + let result: string[] = []; + let candidate = trimmed; + + if (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']') { + candidate = trimmed.slice(1, -1); + } + + // Try JSON parsing after normalizing quotes + try { + let normalized = trimmed; + if (trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1) { + normalized = trimmed.replace(/'/g, '"'); + } + const parsed = JSON.parse(normalized); + if (Array.isArray(parsed)) { + result = parsed; + } + } catch (_) { /* fall back below */ } + + if (!result || !result.length) { + result = candidate.split(/\s*,\s*/); + } + + return result.map((t) => String(t || '').trim()).filter(Boolean); + } + + function deriveTagsFromReasons(reasons: string): string[] { + if (!reasons) return []; + const out: string[] = []; + + // Grab bracketed or quoted lists first + const m = reasons.match(/\[(.*?)\]/); + if (m && m[1]) out.push(...m[1].split(/\s*,\s*/)); + + // Common phrasing: "overlap(s) with A, B" or "by A, B" + const rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig; + let r; + while ((r = rx.exec(reasons))) { + out.push(...(r[2] || '').split(/\s*,\s*/)); + } + + const tagRx = /tag:\s*([^.;]+)/ig; + let tMatch; + while ((tMatch = tagRx.exec(reasons))) { + out.push(...(tMatch[1] || '').split(/\s*,\s*/)); + } + + return out.map((s) => s.trim()).filter(Boolean); + } + + let overlapArr: string[] = []; + if (overlapsAttr) { + const parsedOverlaps = parseTagList(overlapsAttr); + if (parsedOverlaps.length) { + overlapArr = parsedOverlaps; + } else { + overlapArr = [String(overlapsAttr).trim()]; + } + } + + const derivedFromReasons = deriveTagsFromReasons(reasonsRaw); + let allTags = parseTagList(tagsRaw); + + if (!allTags.length && derivedFromReasons.length) { + // Fallback: try to derive tags from reasons text when tags missing + allTags = derivedFromReasons.slice(); + } + + if ((!overlapArr || !overlapArr.length) && derivedFromReasons.length) { + const normalizedAll = (allTags || []).map((t) => ({ raw: t, key: t.toLowerCase() })); + const derivedKeys = new Set(derivedFromReasons.map((t) => t.toLowerCase())); + let intersect = normalizedAll.filter((entry) => derivedKeys.has(entry.key)).map((entry) => entry.raw); + + if (!intersect.length) { + intersect = derivedFromReasons.slice(); + } + + overlapArr = Array.from(new Set(intersect)); + } + + overlapArr = (overlapArr || []).map((t) => String(t || '').trim()).filter(Boolean); + allTags = (allTags || []).map((t) => String(t || '').trim()).filter(Boolean); + + nameEl.textContent = nm; + rarityEl.textContent = rarity; + + const roleLabel = displayLabel(role); + const roleKey = (roleLabel || role || '').toLowerCase(); + const isCommanderRole = roleKey === 'commander'; + + metaEl.textContent = [ + roleLabel ? ('Role: ' + roleLabel) : '', + mana ? ('Mana: ' + mana) : '' + ].filter(Boolean).join(' • '); + + reasonsList.innerHTML = ''; + reasonsRaw.split(';').map((r) => r.trim()).filter(Boolean).forEach((r) => { + const li = document.createElement('li'); + li.style.margin = '2px 0'; + li.textContent = r; + reasonsList.appendChild(li); + }); + + // Build inline tag list with overlap highlighting + if (tagListEl) { + tagListEl.innerHTML = ''; + tagListEl.style.display = 'none'; + tagListEl.setAttribute('aria-hidden', 'true'); + } + + if (overlapsEl) { + if (overlapArr && overlapArr.length) { + overlapsEl.innerHTML = overlapArr.map((o) => { + const label = displayLabel(o); + return `${label}`; + }).join(''); + } else { + overlapsEl.innerHTML = ''; + } + } + + if (tagsEl) { + if (isCommanderRole) { + tagsEl.textContent = ''; + tagsEl.style.display = 'none'; + } else { + let tagText = allTags.map(displayLabel).join(', '); + + // M5: Temporarily append metadata tags for debugging + if (metadataTagsRaw && metadataTagsRaw.trim()) { + const metaTags = metadataTagsRaw.split(',').map((t) => t.trim()).filter(Boolean); + if (metaTags.length) { + const metaText = metaTags.map(displayLabel).join(', '); + tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText); + } + } + + tagsEl.textContent = tagText; + tagsEl.style.display = tagText ? '' : 'none'; + } + } + + if (roleEl) { + roleEl.textContent = roleLabel || ''; + roleEl.style.display = roleLabel ? 'inline-block' : 'none'; + } + + panel.classList.toggle('is-payoff', role === 'payoff'); + panel.classList.toggle('is-commander', isCommanderRole); + + const hasDetails = !forceSimple && ( + !!roleLabel || !!mana || !!rarity || + (reasonsRaw && reasonsRaw.trim()) || + (overlapArr && overlapArr.length) || + (allTags && allTags.length) + ); + + panel.classList.toggle('hcp-simple', !hasDetails); + + if (rightCol) { + rightCol.style.display = hasDetails ? 'flex' : 'none'; + } + + if (bodyEl) { + if (!hasDetails) { + bodyEl.style.display = 'flex'; + bodyEl.style.flexDirection = 'column'; + bodyEl.style.alignItems = 'center'; + bodyEl.style.gap = '12px'; + } else { + bodyEl.style.display = ''; + bodyEl.style.flexDirection = ''; + bodyEl.style.alignItems = ''; + bodyEl.style.gap = ''; + } + } + + const rawName = nm || ''; + let hasBack = rawName.indexOf('//') > -1 || (attr('data-original-name') || '').indexOf('//') > -1; + if (hasBack) hasFlip = true; + + const storageKey = 'mtg:face:' + rawName.toLowerCase(); + const storedFace = (() => { + try { + return localStorage.getItem(storageKey); + } catch (_) { + return null; + } + })(); + + if (storedFace === 'front' || storedFace === 'back') { + card.setAttribute('data-current-face', storedFace); + } + + const chosenFace = card.getAttribute('data-current-face') || 'front'; + lastCard = card; + + function renderHoverFace(face: string): void { + const desiredVersion = 'normal'; + const currentKey = nm + ':' + face + ':' + desiredVersion; + const prevFace = imgEl.getAttribute('data-face'); + const faceChanged = prevFace && prevFace !== face; + + if (imgEl.getAttribute('data-current') !== currentKey) { + // For DFC cards, extract the specific face name for cache lookup + let faceName = nm; + const isDFC = nm.indexOf('//') > -1; + if (isDFC) { + const faces = nm.split('//'); + faceName = (face === 'back') ? faces[1].trim() : faces[0].trim(); + } + + let src = '/api/images/' + desiredVersion + '/' + encodeURIComponent(faceName); + if (isDFC && face === 'back') { + src += '?face=back'; + } + + if (faceChanged) imgEl.style.opacity = '0'; + prefetch(src); + imgEl.src = src; + imgEl.setAttribute('data-current', currentKey); + imgEl.setAttribute('data-face', face); + + imgEl.addEventListener('load', function onLoad() { + imgEl.removeEventListener('load', onLoad); + requestAnimationFrame(() => { imgEl.style.opacity = '1'; }); + }); + } + + if (!(imgEl as any).__errBound) { + (imgEl as any).__errBound = true; + imgEl.addEventListener('error', () => { + const cur = imgEl.getAttribute('src') || ''; + // Fallback from normal to small if image fails to load + if (cur.indexOf('/api/images/normal/') > -1) { + imgEl.src = cur.replace('/api/images/normal/', '/api/images/small/'); + } + }); + } + } + + renderHoverFace(chosenFace); + + // Add DFC flip button to popup panel ONLY on mobile + const checkFlip = (window as any).__dfcHasTwoFaces || (() => false); + if (hasFlip && imgEl && checkFlip(card) && isMobileMode()) { + const imgWrap = imgEl.parentElement; + if (imgWrap && !imgWrap.querySelector('.dfc-toggle')) { + const flipBtn = document.createElement('button'); + flipBtn.type = 'button'; + flipBtn.className = 'dfc-toggle'; + flipBtn.setAttribute('aria-pressed', 'false'); + flipBtn.setAttribute('tabindex', '0'); + flipBtn.innerHTML = ''; + + flipBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if ((window as any).__dfcFlipCard && lastCard) { + // For image elements, find the parent container with the flip button + let cardToFlip = lastCard; + if (lastCard.tagName === 'IMG' && lastCard.parentElement) { + const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); + if (parentWithButton) { + cardToFlip = lastCard.parentElement; + } + } + (window as any).__dfcFlipCard(cardToFlip); + } + }); + + flipBtn.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'f' || ev.key === 'F') { + ev.preventDefault(); + if ((window as any).__dfcFlipCard && lastCard) { + // For image elements, find the parent container with the flip button + let cardToFlip = lastCard; + if (lastCard.tagName === 'IMG' && lastCard.parentElement) { + const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); + if (parentWithButton) { + cardToFlip = lastCard.parentElement; + } + } + (window as any).__dfcFlipCard(cardToFlip); + } + } + }); + + imgWrap.classList.add('dfc-host'); + imgWrap.appendChild(flipBtn); + } + } + + (window as any).__dfcNotifyHover = hasFlip ? (cardRef: Element, face: string) => { + if (cardRef === lastCard) renderHoverFace(face); + } : null; + + if (evt) (window as any).__lastPointerEvent = evt; + + if (isMobileMode()) { + panel.classList.add('mobile'); + panel.style.pointerEvents = 'auto'; + panel.style.maxHeight = '80vh'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.maxHeight = ''; + panel.style.bottom = 'auto'; + } + + panel.style.display = 'block'; + panel.setAttribute('aria-hidden', 'false'); + move(evt); + } + + function hide(): void { + // Blur any focused element inside panel to avoid ARIA focus warning + if (panel.contains(document.activeElement)) { + (document.activeElement as HTMLElement)?.blur(); + } + panel.style.display = 'none'; + panel.setAttribute('aria-hidden', 'true'); + cancelSchedule(); + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + panel.style.bottom = 'auto'; + panel.style.maxHeight = ''; + (window as any).__dfcNotifyHover = null; + } + + document.addEventListener('mousemove', move); + + function getCardFromEl(el: EventTarget | null): Element | null { + if (!el || !(el instanceof Element)) return null; + + if (el.closest) { + const altBtn = el.closest('.alts button[data-card-name]'); + if (altBtn) return altBtn; + } + + // If inside flip button + const btn = el.closest && el.closest('.dfc-toggle'); + if (btn) { + return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card'); + } + + // For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile) + if (el.closest && el.closest('.card-tile')) { + const imgBtn = el.closest('.img-btn'); + if (imgBtn) return imgBtn.closest('.card-tile'); + + // If directly on the image + if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))) { + return el.closest('.card-tile'); + } + + // Don't trigger on other parts of the tile (buttons, text, etc.) + return null; + } + + // Recognized container classes + const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card'); + if (container) return container; + + // Image-based detection (any card image carrying data-card-name) + if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) { + const up = el.closest && el.closest('.stack-card'); + return up || el; + } + + // List view spans (deck summary list mode, finished deck list, etc.) + if (el.hasAttribute && el.hasAttribute('data-card-name')) return el; + + return null; + } + + document.addEventListener('pointermove', (e) => { (window as any).__lastPointerEvent = e; }); + + document.addEventListener('pointerover', (e) => { + if (isMobileMode()) return; + const card = getCardFromEl(e.target); + if (!card) return; + + // If hovering flip button, refresh immediately (no activation delay) + if (e.target instanceof Element && e.target.closest && e.target.closest('.dfc-toggle')) { + show(card, e); + return; + } + + if (lastCard === card && panel.style.display === 'block') return; + schedule(card, e); + }); + + document.addEventListener('pointerout', (e) => { + if (isMobileMode()) return; + const relCard = getCardFromEl(e.relatedTarget); + if (relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button) + if (!panel.contains(e.relatedTarget as Node)) { + cancelSchedule(); + if (!relCard) hide(); + } + }); + + document.addEventListener('click', (e) => { + if (!isMobileMode()) return; + if (panel.contains(e.target as Node)) return; + if (e.target instanceof Element && e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return; + if (e.target instanceof Element && e.target.closest && e.target.closest('button, input, select, textarea, a')) return; + + const card = getCardFromEl(e.target); + if (card) { + cancelSchedule(); + const rect = card.getBoundingClientRect(); + const syntheticEvt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + show(card, syntheticEvt); + } else if (panel.style.display === 'block') { + hide(); + } + }); + + // Expose show function for external refresh (flip updates) + (window as any).__hoverShowCard = (card: Element) => { + const ev = (window as any).__lastPointerEvent || { + clientX: card.getBoundingClientRect().left + 12, + clientY: card.getBoundingClientRect().top + 12 + }; + show(card, ev); + }; + + (window as any).hoverShowByName = (name: string) => { + try { + const el = document.querySelector('[data-card-name="' + CSS.escape(name) + '"]'); + if (el) { + (window as any).__hoverShowCard( + el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el + ); + } + } catch (_) { } + }; + + // Keyboard accessibility & focus traversal + document.addEventListener('focusin', (e) => { + const card = e.target instanceof Element && e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); + if (card) { + show(card, { + clientX: card.getBoundingClientRect().left + 10, + clientY: card.getBoundingClientRect().top + 10 + }); + } + }); + + document.addEventListener('focusout', (e) => { + const next = e.relatedTarget instanceof Element && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); + if (!next) hide(); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hide(); + }); + + // Compact mode event listener + document.addEventListener('mtg:hoverCompactToggle', () => { + panel.classList.toggle('compact-img', !!(window as any).__hoverCompactMode); + }); + } + + document.addEventListener('htmx:afterSwap', setup); + document.addEventListener('DOMContentLoaded', setup); + setup(); +}; + +// Global compact mode toggle function +(window as any).__initHoverCompactMode = function initHoverCompactMode(): void { + (window as any).toggleHoverCompactMode = (state?: boolean) => { + if (typeof state === 'boolean') { + (window as any).__hoverCompactMode = state; + } else { + (window as any).__hoverCompactMode = !(window as any).__hoverCompactMode; + } + document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle')); + }; +}; + +// Auto-initialize on load +if (typeof window !== 'undefined') { + (window as any).__initHoverCardPanel(); + (window as any).__initHoverCompactMode(); +} diff --git a/code/web/static/ts/cardImages.ts b/code/web/static/ts/cardImages.ts new file mode 100644 index 0000000..b7f8455 --- /dev/null +++ b/code/web/static/ts/cardImages.ts @@ -0,0 +1,153 @@ +/** + * Card Image URL Builders & Retry Logic + * + * Utilities for constructing card image URLs and handling image load failures + * with automatic fallback to different image sizes. + * + * Features: + * - Build card image URLs with face (front/back) support + * - Build Scryfall image URLs with version control + * - Automatic retry on image load failure (different sizes) + * - Cache-busting support for failed loads + * - HTMX swap integration for dynamic content + * + * NOTE: This module exposes functions globally on window for browser compatibility + */ + +interface ImageRetryState { + vi: number; // Current version index + nocache: number; // Cache-busting flag (0 or 1) + versions: string[]; // Image versions to try ['small', 'normal', 'large'] +} + +const IMG_FLAG = '__cardImgRetry'; + +/** + * Normalize card name by removing synergy suffixes + */ +function normalizeCardName(raw: string): string { + if (!raw) return raw; + const normalize = (window as any).__normalizeCardName || ((name: string) => { + if (!name) return name; + const m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(name); + if (m) return m[1].trim(); + return name; + }); + return normalize(raw); +} + +/** + * Build card image URL with face support (front/back) + * @param name - Card name + * @param version - Image version ('small', 'normal', 'large') + * @param nocache - Add cache-busting timestamp + * @param face - Card face ('front' or 'back') + */ +function buildCardUrl(name: string, version?: string, nocache?: boolean, face?: string): string { + name = normalizeCardName(name); + const q = encodeURIComponent(name || ''); + let url = '/api/images/' + (version || 'normal') + '/' + q; + if (face === 'back') url += '?face=back'; + if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now(); + return url; +} + +/** + * Build Scryfall image URL + * @param name - Card name + * @param version - Image version ('small', 'normal', 'large') + * @param nocache - Add cache-busting timestamp + */ +function buildScryfallImageUrl(name: string, version?: string, nocache?: boolean): string { + name = normalizeCardName(name); + const q = encodeURIComponent(name || ''); + let url = '/api/images/' + (version || 'normal') + '/' + q; + if (nocache) url += '?t=' + Date.now(); + return url; +} + +/** + * Bind error handler to an image element for automatic retry with fallback versions + * @param img - Image element with data-card-name attribute + * @param versions - Array of image versions to try in order + */ +function bindCardImageRetry(img: HTMLImageElement, versions?: string[]): void { + try { + if (!img || (img as any)[IMG_FLAG]) return; + const name = img.getAttribute('data-card-name') || ''; + if (!name) return; + + // Default versions: normal -> large + const versionList = versions && versions.length ? versions.slice() : ['normal', 'large']; + (img as any)[IMG_FLAG] = { + vi: 0, + nocache: 0, + versions: versionList + } as ImageRetryState; + + img.addEventListener('error', function() { + const st = (img as any)[IMG_FLAG] as ImageRetryState; + if (!st) return; + + // Try next version + if (st.vi < st.versions.length - 1) { + st.vi += 1; + img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); + } + // Try cache-busting current version + else if (!st.nocache) { + st.nocache = 1; + img.src = buildScryfallImageUrl(name, st.versions[st.vi], true); + } + }); + + // If initial load already failed before binding, try next immediately + if (img.complete && img.naturalWidth === 0) { + const st = (img as any)[IMG_FLAG] as ImageRetryState; + const current = img.src || ''; + const first = buildScryfallImageUrl(name, st.versions[0], false); + + // Check if current src matches first version + if (current.indexOf(encodeURIComponent(name)) !== -1 && + current.indexOf('version=' + st.versions[0]) !== -1) { + st.vi = Math.min(1, st.versions.length - 1); + img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); + } else { + // Re-trigger current request (may succeed if transient error) + img.src = current; + } + } + } catch (_) { + // Silently fail - image retry is a nice-to-have feature + } +} + +/** + * Bind retry handlers to all card images in the document + */ +function bindAllCardImageRetries(): void { + document.querySelectorAll('img[data-card-name]').forEach((img) => { + // Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks + const versions = (img.classList && img.classList.contains('card-thumb')) + ? ['small', 'normal', 'large'] + : ['normal', 'large']; + bindCardImageRetry(img as HTMLImageElement, versions); + }); +} + +// Expose globally for browser usage +(window as any).__initCardImages = function initCardImages(): void { + // Expose retry binding globally for dynamic content + (window as any).bindAllCardImageRetries = bindAllCardImageRetries; + + // Initial bind + bindAllCardImageRetries(); + + // Re-bind after HTMX swaps + document.addEventListener('htmx:afterSwap', bindAllCardImageRetries); +}; + +// Auto-initialize on load +if (typeof window !== 'undefined') { + (window as any).__initCardImages(); +} diff --git a/code/web/static/ts/components.ts b/code/web/static/ts/components.ts new file mode 100644 index 0000000..b9493b2 --- /dev/null +++ b/code/web/static/ts/components.ts @@ -0,0 +1,382 @@ +/** + * M3 Component Library - TypeScript Utilities + * + * Core functions for interactive components: + * - Card flip button (dual-faced cards) + * - Collapsible panels + * - Card popups + * - Modal management + * + * Migrated from components.js with TypeScript types + */ + +// ============================================ +// TYPE DEFINITIONS +// ============================================ + +interface CardPopupOptions { + tags?: string[]; + highlightTags?: string[]; + role?: string; + layout?: string; +} + +// ============================================ +// CARD FLIP FUNCTIONALITY +// ============================================ + +/** + * Flip a dual-faced card image between front and back faces + * @param button - The flip button element + */ +function flipCard(button: HTMLElement): void { + const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; + if (!container) return; + + const img = container.querySelector('img') as HTMLImageElement | null; + if (!img) return; + + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length < 2) return; + + // Determine current face (default to 0 = front) + const currentFace = parseInt(img.dataset.currentFace || '0', 10); + const nextFace = currentFace === 0 ? 1 : 0; + const faceName = faces[nextFace]; + + // Determine image version based on container + const isLarge = container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image'); + const version = isLarge ? 'normal' : 'small'; + + // Update image source + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; + img.alt = `${faceName} image`; + img.dataset.currentFace = nextFace.toString(); + + // Update button aria-label + const otherFace = faces[currentFace]; + button.setAttribute('aria-label', `Flip to ${otherFace}`); +} + +/** + * Reset all card images to show front face + * Useful when navigating between pages or clearing selections + */ +function resetCardFaces(): void { + document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length > 1) { + const frontFace = faces[0]; + const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; + const isLarge = container && (container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image')); + const version = isLarge ? 'normal' : 'small'; + + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; + img.alt = `${frontFace} image`; + img.dataset.currentFace = '0'; + } + }); +} + +// ============================================ +// COLLAPSIBLE PANEL FUNCTIONALITY +// ============================================ + +/** + * Toggle a collapsible panel's expanded/collapsed state + * @param panelId - The ID of the panel element + */ +function togglePanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + + // Toggle state + button.setAttribute('aria-expanded', (!isExpanded).toString()); + content.style.display = isExpanded ? 'none' : 'block'; + + // Toggle classes + panel.classList.toggle('panel-expanded', !isExpanded); + panel.classList.toggle('panel-collapsed', isExpanded); +} + +/** + * Expand a collapsible panel + * @param panelId - The ID of the panel element + */ +function expandPanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'true'); + content.style.display = 'block'; + panel.classList.add('panel-expanded'); + panel.classList.remove('panel-collapsed'); +} + +/** + * Collapse a collapsible panel + * @param panelId - The ID of the panel element + */ +function collapsePanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'false'); + content.style.display = 'none'; + panel.classList.add('panel-collapsed'); + panel.classList.remove('panel-expanded'); +} + +// ============================================ +// MODAL MANAGEMENT +// ============================================ + +/** + * Open a modal by ID + * @param modalId - The ID of the modal element + */ +function openModal(modalId: string): void { + const modal = document.getElementById(modalId); + if (!modal) return; + + (modal as HTMLElement).style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // Focus first focusable element in modal + const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusable) { + setTimeout(() => focusable.focus(), 100); + } +} + +/** + * Close a modal by ID or element + * @param modalOrId - Modal element or ID + */ +function closeModal(modalOrId: string | HTMLElement): void { + const modal = typeof modalOrId === 'string' + ? document.getElementById(modalOrId) + : modalOrId; + + if (!modal) return; + + modal.remove(); + + // Restore body scroll if no other modals are open + if (!document.querySelector('.modal')) { + document.body.style.overflow = ''; + } +} + +/** + * Close all open modals + */ +function closeAllModals(): void { + document.querySelectorAll('.modal').forEach(modal => modal.remove()); + document.body.style.overflow = ''; +} + +// ============================================ +// CARD POPUP FUNCTIONALITY +// ============================================ + +/** + * Show card details popup on hover or tap + * @param cardName - The card name + * @param options - Popup options + */ +function showCardPopup(cardName: string, options: CardPopupOptions = {}): void { + // Remove any existing popup + closeCardPopup(); + + const { + tags = [], + highlightTags = [], + role = '', + layout = 'normal' + } = options; + + const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); + const baseName = cardName.split(' // ')[0]; + + // Create popup HTML + const popup = document.createElement('div'); + popup.className = 'card-popup'; + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-label', `${cardName} details`); + + let tagsHTML = ''; + if (tags.length > 0) { + tagsHTML = '
      '; + 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 new file mode 100644 index 0000000..bb7fb65 --- /dev/null +++ b/code/web/static/ts/types.ts @@ -0,0 +1,105 @@ +/* Shared TypeScript type definitions for MTG Deckbuilder web app */ + +// Toast system types +export interface ToastOptions { + duration?: number; +} + +// State management types +export interface StateManager { + get(key: string, def?: any): any; + set(key: string, val: any): void; + inHash(obj: Record): void; + readHash(): URLSearchParams; +} + +// Telemetry types +export interface TelemetryManager { + send(eventName: string, data?: Record): void; +} + +// Skeleton system types +export interface SkeletonManager { + show(context?: HTMLElement | Document): void; + hide(context?: HTMLElement | Document): void; +} + +// Card popup types (from components.ts) +export interface CardPopupOptions { + tags?: string[]; + highlightTags?: string[]; + role?: string; + layout?: string; + showActions?: boolean; +} + +// HTMX event detail types +export interface HtmxResponseErrorDetail { + xhr?: XMLHttpRequest; + path?: string; + target?: HTMLElement; +} + +export interface HtmxEventDetail { + target?: HTMLElement; + elt?: HTMLElement; + path?: string; + xhr?: XMLHttpRequest; +} + +// HTMX cache interface +export interface HtmxCache { + get(key: string): any; + set(key: string, html: string, ttl?: number, meta?: any): void; + apply(elt: any, detail: any, entry: any): void; + buildKey(detail: any, elt: any): string; + ttlFor(elt: any): number; + prefetch(url: string, opts?: any): void; +} + +// Global window extensions +declare global { + interface Window { + __mtgState: StateManager; + toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement; + toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement; + appTelemetry: TelemetryManager; + skeletons: SkeletonManager; + __telemetryEndpoint?: string; + showCardPopup?: (cardName: string, options?: CardPopupOptions) => void; + dismissCardPopup?: () => void; + flipCard?: (button: HTMLElement) => void; + htmxCache?: HtmxCache; + htmx?: any; // HTMX library - use any for external library + initHtmxDebounce?: () => void; + scrollCardIntoView?: (card: HTMLElement) => void; + __virtGlobal?: any; + __virtHotkeyBound?: boolean; + } + + interface CustomEvent { + readonly detail: T; + } + + // HTMX custom events + interface DocumentEventMap { + 'htmx:responseError': CustomEvent; + 'htmx:sendError': CustomEvent; + 'htmx:afterSwap': CustomEvent; + 'htmx:beforeRequest': CustomEvent; + 'htmx:afterSettle': CustomEvent; + 'htmx:afterRequest': CustomEvent; + } + + interface HTMLElement { + __hxCacheKey?: string; + __hxCacheTTL?: number; + } + + interface Element { + __hxPrefetched?: boolean; + } +} + +// Empty export to make this a module file +export {}; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 72996c3..c17b53f 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -6,10 +6,6 @@ MTG Deckbuilder - + + + + \ No newline at end of file diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html index cc5277c..7afd820 100644 --- a/code/web/templates/build/_new_deck_tags.html +++ b/code/web/templates/build/_new_deck_tags.html @@ -15,15 +15,15 @@
      -
      {{ pname }}
      +
      {{ pname }}
      {% if partner_preview_payload %} {% set partner_secondary_name = partner_preview_payload.secondary_name %} {% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %} {% if not partner_image_url and partner_secondary_name %} - {% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %} + {% set partner_image_url = partner_secondary_name|card_image('normal') %} {% endif %} {% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %} {% if not partner_href and partner_secondary_name %} @@ -224,36 +224,83 @@ }); } document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); }); - function updatePartnerRecommendations(tags){ - if (!reco) return; - Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); }); - var unique = []; + + function updatePartnerTags(partnerTags){ + if (!list || !reco) return; + + // Remove old partner-added chips from available list + Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); }); + + // Deduplicate: remove partner tags from recommended section to avoid showing them twice + if (partnerTags && partnerTags.length > 0) { + var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); }); + Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){ + var tag = btn.dataset.tag || ''; + if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) { + btn.remove(); + } + }); + } + + // Get existing tags from the available list (original server-rendered ones) + var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){ + return { + element: b, + tag: (b.dataset.tag || '').trim(), + tagLower: (b.dataset.tag || '').trim().toLowerCase() + }; + }); + + // Build combined list: existing + new partner tags + var combined = []; var seen = new Set(); - (Array.isArray(tags) ? tags : []).forEach(function(tag){ + + // Add existing tags first + existingTags.forEach(function(item){ + if (!item.tag || seen.has(item.tagLower)) return; + seen.add(item.tagLower); + combined.push({ tag: item.tag, element: item.element, isPartner: false }); + }); + + // Add new partner tags + (Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){ var value = String(tag || '').trim(); if (!value) return; var key = value.toLowerCase(); if (seen.has(key)) return; seen.add(key); - unique.push(value); + combined.push({ tag: value, element: null, isPartner: true }); }); - var insertBefore = selAll && selAll.parentElement === reco ? selAll : null; - unique.forEach(function(tag){ - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'chip chip-reco partner-suggestion'; - btn.dataset.tag = tag; - btn.title = 'Synergizes with selected partner pairing'; - btn.textContent = '★ ' + tag; - if (insertBefore){ reco.insertBefore(btn, insertBefore); } - else { reco.appendChild(btn); } + + // Sort alphabetically + combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); }); + + // Re-render the list in sorted order + list.innerHTML = ''; + combined.forEach(function(item){ + if (item.element) { + // Re-append existing element + list.appendChild(item.element); + } else { + // Create new partner-added chip + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chip partner-added'; + btn.dataset.tag = item.tag; + btn.title = 'From combined partner themes'; + btn.textContent = item.tag; + list.appendChild(btn); + } }); - var hasAny = reco.querySelectorAll('button.chip-reco').length > 0; + + // Update visibility of recommended section + var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0; if (recoBlock){ - recoBlock.style.display = hasAny ? '' : 'none'; - recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0'); + recoBlock.style.display = hasAnyReco ? '' : 'none'; + recoBlock.setAttribute('data-has-reco', hasAnyReco ? '1' : '0'); } - if (selAll){ selAll.style.display = hasAny ? '' : 'none'; } + if (selAll){ selAll.style.display = hasAnyReco ? '' : 'none'; } + updateUI(); } @@ -264,11 +311,11 @@ if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){ tags = detail.payload.theme_tags; } - updatePartnerRecommendations(tags); + updatePartnerTags(tags); }); var initialPartnerTags = readPartnerPreviewTags(); - updatePartnerRecommendations(initialPartnerTags); + updatePartnerTags(initialPartnerTags); updateUI(); })(); diff --git a/code/web/templates/build/_partner_controls.html b/code/web/templates/build/_partner_controls.html index 202bf7d..3de6a96 100644 --- a/code/web/templates/build/_partner_controls.html +++ b/code/web/templates/build/_partner_controls.html @@ -106,7 +106,7 @@ {% if partner_preview %} {% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %} {% if not preview_image and partner_preview.secondary_name %} - {% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %} + {% set preview_image = partner_preview.secondary_name|card_image('normal') %} {% endif %} {% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %} {% if not preview_href and partner_preview.secondary_name %} @@ -463,7 +463,7 @@ }; function buildCardImageUrl(name){ if (!name) return ''; - return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal'; + return '/api/images/normal/' + encodeURIComponent(name); } function buildScryfallUrl(name){ if (!name) return ''; @@ -528,7 +528,9 @@ var colorLabel = payload.color_label || ''; var secondaryName = payload.secondary_name || payload.name || ''; var primary = payload.primary_name || primaryName; - var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : []; + // Ensure theme_tags is always an array, even if it comes as a string or other type + var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : + (typeof payload.theme_tags === 'string' ? payload.theme_tags.split(',').map(function(t){ return t.trim(); }).filter(Boolean) : []); var imageUrl = payload.secondary_image_url || payload.image_url || ''; if (!imageUrl && secondaryName){ imageUrl = buildCardImageUrl(secondaryName); diff --git a/code/web/templates/build/_step1.html b/code/web/templates/build/_step1.html index 65e61b8..dfea715 100644 --- a/code/web/templates/build/_step1.html +++ b/code/web/templates/build/_step1.html @@ -39,7 +39,7 @@
      @@ -66,8 +66,8 @@ {% endif %} {% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %} -
      - No results for “{{ query }}”. Try a shorter name or a different spelling. +
      + No results for "{{ query }}". Try a shorter name or a different spelling.
      {% endif %} @@ -77,7 +77,7 @@ {# Strip synergy annotation for Scryfall search and image fuzzy param #} {% set sel_base = (selected.split(' - Synergy (')[0] if ' - Synergy (' in selected else selected) %} - {{ selected }} card image + {{ selected }} card image
      diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html index 6ad2ef7..0186eaa 100644 --- a/code/web/templates/build/_step2.html +++ b/code/web/templates/build/_step2.html @@ -6,7 +6,7 @@ {# Strip synergy annotation for Scryfall search and image fuzzy param #} {% set commander_base = (commander.name.split(' - Synergy (')[0] if ' - Synergy (' in commander.name else commander.name) %} - {{ 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 = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=normal' %} + {% set partner_image_url = partner_name_base|card_image('normal') %} {% endif %} {% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %} {% if not partner_href and partner_name_base %} @@ -35,14 +35,14 @@ {% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}> {% if partner_href %}{% endif %} {% if partner_name_base %} - {{ (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 8231e5b..95e7a39 100644 --- a/code/web/templates/build/_step3.html +++ b/code/web/templates/build/_step3.html @@ -5,7 +5,7 @@ {# Ensure synergy annotation suffix is stripped for Scryfall query and image fuzzy param #} {% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %} - {{ commander }} card image + {{ commander }} card image
      diff --git a/code/web/templates/build/_step3_skeleton.html b/code/web/templates/build/_step3_skeleton.html new file mode 100644 index 0000000..02f6a82 --- /dev/null +++ b/code/web/templates/build/_step3_skeleton.html @@ -0,0 +1,32 @@ +
      +
      + +
      +
      + +
      +
      +

      Automating choices...

      +

      {{ automation_message }}

      +
      + + {# Hidden form that auto-submits with defaults #} + +
      +
      +
      + + diff --git a/code/web/templates/build/_step4.html b/code/web/templates/build/_step4.html index 47b986f..ca989b5 100644 --- a/code/web/templates/build/_step4.html +++ b/code/web/templates/build/_step4.html @@ -5,7 +5,7 @@ {# Strip synergy annotation for Scryfall search and image fuzzy param #} {% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %} - {{ commander }} card image + {{ commander }} card image
      diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index b1d4b88..58b7237 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -35,7 +35,7 @@ {% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %} {% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %} {% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}> - {{ 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 @@ -