diff --git a/.env.example b/.env.example index 75119f7..5921ede 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,9 @@ WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1" WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4" WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0" +# Card Image Caching (optional, uses Scryfall bulk data API) +CACHE_CARD_IMAGES=1 # dockerhub: CACHE_CARD_IMAGES="1" (1=download images to card_files/images/, 0=fetch from Scryfall API on demand) + # Build Stage Ordering WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill diff --git a/.gitignore b/.gitignore index f8e1a3c..6de24ec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ RELEASE_NOTES.md test.py +test_*.py !test_exclude_cards.txt !test_include_exclude_config.json @@ -40,4 +41,14 @@ logs/ logs/* !logs/perf/ logs/perf/* -!logs/perf/theme_preview_warm_baseline.json \ No newline at end of file +!logs/perf/theme_preview_warm_baseline.json + +# Node.js and build artifacts +node_modules/ +code/web/static/js/ +code/web/static/styles.css +*.js.map + +# Keep TypeScript sources and Tailwind CSS input +!code/web/static/ts/ +!code/web/static/tailwind.css \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c6db31c..4fbd36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,27 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -- **Build X and Compare** feature: Build multiple decks with same configuration and compare results side-by-side - - Build 1-10 decks in parallel to see variance from card selection randomness - - Real-time progress tracking with dynamic time estimates based on color count - - Comparison view with card overlap statistics and individual build summaries - - Smart filtering excludes guaranteed cards (basics, staples) from "Most Common Cards" - - Card hover support throughout comparison interface - - Rebuild button to rerun same configuration - - Export all decks as ZIP archive +- **Card Image Caching**: Optional local image cache for faster card display + - Downloads card images from Scryfall bulk data (respects API guidelines) + - Graceful fallback to Scryfall API for uncached images + - Enabled via `CACHE_CARD_IMAGES=1` environment variable + - Integrated with setup/tagging process + - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) +- **Component Library**: Living documentation of reusable UI components at `/docs/components` + - Interactive examples of all buttons, modals, forms, cards, and panels + - Jinja2 macros for consistent component usage + - Component partial templates for reuse across pages + +### Changed +- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture + - Tailwind CSS v3 with custom MTG color palette + - PostCSS build pipeline with autoprefixer + - Reduced inline styles in templates (moved to shared CSS classes) + - Organized CSS into functional sections with clear documentation +- **Docker Build Optimization**: Improved developer experience + - Hot reload enabled for templates and static files + - Volume mounts for rapid iteration without rebuilds +- **Template Modernization**: Migrated templates to use component system - **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) - 10% bonus for cards appearing in 80%+ of builds @@ -27,9 +40,21 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) - Detailed progress logging for multi-build orchestration - User guide: `docs/user_guides/batch_build_compare.md` +- **Web UI Component Library**: Standardized UI components for consistent design across all pages + - 5 component partial template files (buttons, modals, forms, cards, panels) + - ~900 lines of component CSS styles + - Interactive JavaScript utilities (components.js) + - Living component library page at `/docs/components` + - 1600+ lines developer documentation (component_catalog.md) +- **Custom UI Enhancements**: + - Darker gray styling for home page buttons + - Visual highlighting for selected theme chips in deck builder ### Changed -_None_ +- Optimized Docker build process: Reduced build time from ~134s to ~6s + - Removed redundant card_files copy (already mounted as volume) + - Added volume mounts for templates and static files (hot reload support) +- Migrated 5 templates to new component system (home, 404, 500, setup, commanders) ### Removed _None_ @@ -38,7 +63,7 @@ _None_ _None_ ### Performance -_None_ +- Docker hot reload now works for CSS and template changes (no rebuild required) ### Deprecated _None_ diff --git a/DOCKER.md b/DOCKER.md index 398140c..99c9907 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -283,6 +283,7 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. | | `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. | +| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/Dockerfile b/Dockerfile index 7f6f0ce..1f76105 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,21 +10,42 @@ ENV PYTHONUNBUFFERED=1 ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} -# Install system dependencies if needed +# Install system dependencies including Node.js RUN apt-get update && apt-get install -y \ gcc \ + curl \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Copy requirements first for better caching +# Copy package files for Node.js dependencies +COPY package.json package-lock.json* ./ + +# Install Node.js dependencies +RUN npm install + +# Copy Tailwind/TypeScript config files +COPY tailwind.config.js postcss.config.js tsconfig.json ./ + +# Copy requirements for Python dependencies (for better caching) COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy application code +# Copy Python application code (includes templates needed for Tailwind) COPY code/ ./code/ COPY mypy.ini . +# Tailwind source is already in code/web/static/tailwind.css from COPY code/ +# TypeScript sources are in code/web/static/ts/ from COPY code/ + +# Force fresh CSS build by removing any copied styles.css +RUN rm -f ./code/web/static/styles.css + +# Build CSS and TypeScript +RUN npm run build + # Copy default configs in two locations: # 1) /app/config is the live path (may be overlaid by a volume) # 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted @@ -36,7 +57,9 @@ RUN mkdir -p owned_cards # Store in /.defaults/card_files so it persists after volume mount RUN mkdir -p /.defaults/card_files # Copy entire card_files directory (will include cache if present, empty if not) -COPY card_files/ /.defaults/card_files/ +# COMMENTED OUT FOR LOCAL DEV: card_files is mounted as volume anyway +# Uncomment for production builds or CI/CD +# COPY card_files/ /.defaults/card_files/ # Create necessary directories as mount points RUN mkdir -p deck_files logs csv_files card_files config /.defaults diff --git a/README.md b/README.md index e979b3a..5d46b02 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. | | `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). | +| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). Requires ~3-6 GB. See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c71d6af..ce0fdbf 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,24 +3,27 @@ ## [Unreleased] ### Summary -Major new feature: Build X and Compare with Intelligent Synergy Builder. Run the same deck configuration multiple times to see variance, compare results side-by-side, and create optimized "best-of" decks. +Web UI improvements with Tailwind CSS migration, component library, and optional card image caching for faster performance. ### Added -- **Build X and Compare**: Build 1-10 decks in parallel with same configuration - - Side-by-side comparison with card overlap statistics - - Smart filtering of guaranteed cards - - Rebuild button for quick iterations - - ZIP export of all builds -- **Synergy Builder**: Create optimized deck from multiple builds - - Intelligent scoring (frequency + EDHREC + themes) - - Color-coded synergy preview - - Full metadata export (CSV/TXT/JSON) - - Partner commander support -- Feature flag: `ENABLE_BATCH_BUILD` (default: on) -- User guide: `docs/user_guides/batch_build_compare.md` +- **Card Image Caching**: Optional local image cache for faster card display + - Downloads card images from Scryfall bulk data + - Graceful fallback to Scryfall API for uncached images + - Enable with `CACHE_CARD_IMAGES=1` environment variable + - Intelligent statistics caching (weekly refresh, matching card data staleness) +- **Component Library**: Living documentation at `/docs/components` + - Interactive examples of all UI components + - Reusable Jinja2 macros for consistent design + - Component partial templates for reuse across pages ### Changed -_None_ +- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture + - Tailwind CSS v3 with custom MTG color palette + - PostCSS build pipeline with autoprefixer + - Minimized inline styles in favor of shared CSS classes +- **Docker Build Optimization**: Improved developer experience + - Hot reload for templates and CSS (no rebuild needed) +- **Template Modernization**: Migrated templates to use component system ### Removed _None_ @@ -29,10 +32,14 @@ _None_ _None_ ### Performance -_None_ +- Hot reload for CSS/template changes (no Docker rebuild needed) +- Optional image caching reduces Scryfall API calls +- Faster page loads with optimized CSS ### For Users -_No changes yet_ +- Faster card image loading with optional caching +- Cleaner, more consistent web UI design +- Improved page load performance ### Deprecated _None_ diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py index 86dedd4..b941f30 100644 --- a/code/deck_builder/background_loader.py +++ b/code/deck_builder/background_loader.py @@ -1,22 +1,18 @@ -"""Loader for background cards derived from `background_cards.csv`.""" +"""Loader for background cards derived from all_cards.parquet.""" from __future__ import annotations import ast -import csv +import re from dataclasses import dataclass from functools import lru_cache from pathlib import Path -import re -from typing import Mapping, Tuple +from typing import Any, Mapping, Tuple from logging_util import get_logger from deck_builder.partner_background_utils import analyze_partner_background -from path_util import csv_dir LOGGER = get_logger(__name__) -BACKGROUND_FILENAME = "background_cards.csv" - @dataclass(frozen=True, slots=True) class BackgroundCard: @@ -57,7 +53,7 @@ class BackgroundCatalog: def load_background_cards( source_path: str | Path | None = None, ) -> BackgroundCatalog: - """Load and cache background card data.""" + """Load and cache background card data from all_cards.parquet.""" resolved = _resolve_background_path(source_path) try: @@ -65,7 +61,7 @@ def load_background_cards( mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) size = stat.st_size except FileNotFoundError: - raise FileNotFoundError(f"Background CSV not found at {resolved}") from None + raise FileNotFoundError(f"Background data not found at {resolved}") from None entries, version = _load_background_cards_cached(str(resolved), mtime_ns) etag = f"{size}-{mtime_ns}-{len(entries)}" @@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B if not path.exists(): return tuple(), "unknown" - with path.open("r", encoding="utf-8", newline="") as handle: - first_line = handle.readline() - version = "unknown" - if first_line.startswith("#"): - version = _parse_version(first_line) - else: - handle.seek(0) - reader = csv.DictReader(handle) - if reader.fieldnames is None: - return tuple(), version - entries = _rows_to_cards(reader) + try: + import pandas as pd + df = pd.read_parquet(path, engine="pyarrow") + + # Filter for background cards + if 'isBackground' not in df.columns: + LOGGER.warning("isBackground column not found in %s", path) + return tuple(), "unknown" + + df_backgrounds = df[df['isBackground']].copy() + + if len(df_backgrounds) == 0: + LOGGER.warning("No background cards found in %s", path) + return tuple(), "unknown" + + entries = _rows_to_cards(df_backgrounds) + version = "parquet" + + except Exception as e: + LOGGER.error("Failed to load backgrounds from %s: %s", path, e) + return tuple(), "unknown" frozen = tuple(entries) return frozen, version def _resolve_background_path(override: str | Path | None) -> Path: + """Resolve path to all_cards.parquet.""" if override: return Path(override).resolve() - return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve() + # Use card_files/processed/all_cards.parquet + return Path("card_files/processed/all_cards.parquet").resolve() -def _parse_version(line: str) -> str: - tokens = line.lstrip("# ").strip().split() - for token in tokens: - if "=" not in token: - continue - key, value = token.split("=", 1) - if key == "version": - return value - return "unknown" - - -def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]: +def _rows_to_cards(df) -> list[BackgroundCard]: + """Convert DataFrame rows to BackgroundCard objects.""" entries: list[BackgroundCard] = [] seen: set[str] = set() - for raw in reader: - if not raw: + + for _, row in df.iterrows(): + if row.empty: continue - card = _row_to_card(raw) + card = _row_to_card(row) if card is None: continue key = card.display_name.lower() @@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]: continue seen.add(key) entries.append(card) + entries.sort(key=lambda card: card.display_name) return entries -def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: - name = _clean_str(row.get("name")) - face_name = _clean_str(row.get("faceName")) or None +def _row_to_card(row) -> BackgroundCard | None: + """Convert a DataFrame row to a BackgroundCard.""" + # Helper to safely get values from DataFrame row + def get_val(key: str): + try: + if hasattr(row, key): + val = getattr(row, key) + # Handle pandas NA/None + if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__): + return None + return val + return None + except Exception: + return None + + name = _clean_str(get_val("name")) + face_name = _clean_str(get_val("faceName")) or None display = face_name or name if not display: return None - type_line = _clean_str(row.get("type")) - oracle_text = _clean_multiline(row.get("text")) - raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags"))) + type_line = _clean_str(get_val("type")) + oracle_text = _clean_multiline(get_val("text")) + raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags"))) detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags) if not detection.is_background: return None @@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: face_name=face_name, display_name=display, slug=_slugify(display), - color_identity=_parse_color_list(row.get("colorIdentity")), - colors=_parse_color_list(row.get("colors")), - mana_cost=_clean_str(row.get("manaCost")), - mana_value=_parse_float(row.get("manaValue")), + color_identity=_parse_color_list(get_val("colorIdentity")), + colors=_parse_color_list(get_val("colors")), + mana_cost=_clean_str(get_val("manaCost")), + mana_value=_parse_float(get_val("manaValue")), type_line=type_line, oracle_text=oracle_text, - keywords=tuple(_split_list(row.get("keywords"))), + keywords=tuple(_split_list(get_val("keywords"))), theme_tags=tuple(tag for tag in raw_theme_tags if tag), raw_theme_tags=raw_theme_tags, - edhrec_rank=_parse_int(row.get("edhrecRank")), - layout=_clean_str(row.get("layout")) or "normal", - side=_clean_str(row.get("side")) or None, + edhrec_rank=_parse_int(get_val("edhrecRank")), + layout=_clean_str(get_val("layout")) or "normal", + side=_clean_str(get_val("side")) or None, ) @@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str: def _parse_literal_list(value: object) -> list[str]: if value is None: return [] - if isinstance(value, (list, tuple, set)): + + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + # Handle lists, tuples, sets, and numpy arrays + if isinstance(value, (list, tuple, set)) or is_numpy: return [str(item).strip() for item in value if str(item).strip()] + text = str(value).strip() if not text: return [] @@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]: def _split_list(value: object) -> list[str]: + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + if isinstance(value, (list, tuple, set)) or is_numpy: + return [str(item).strip() for item in value if str(item).strip()] + text = _clean_str(value) if not text: return [] @@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]: def _parse_color_list(value: object) -> Tuple[str, ...]: + # Check if it's a numpy array (from Parquet/pandas) + is_numpy = False + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + pass + + if isinstance(value, (list, tuple, set)) or is_numpy: + parts = [str(item).strip().upper() for item in value if str(item).strip()] + return tuple(parts) + text = _clean_str(value) if not text: return tuple() diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 36ab3fe..a1ae03a 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool: return False +def _extract_colors_from_land_type(type_line: str) -> List[str]: + """Extract mana colors from basic land types in a type line. + + Args: + type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains") + + Returns: + List of color letters (e.g., ['R'], ['G', 'W']) + """ + if not isinstance(type_line, str): + return [] + type_lower = type_line.lower() + colors = [] + basic_land_colors = { + 'plains': 'W', + 'island': 'U', + 'swamp': 'B', + 'mountain': 'R', + 'forest': 'G', + } + for land_type, color in basic_land_colors.items(): + if land_type in type_lower: + colors.append(color) + return colors + + def _resolved_csv_dir(base_dir: str | None = None) -> str: try: if base_dir: @@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: return {} # Select only needed columns - usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName'] + # M9: Added backType to detect MDFC lands where land is on back face + # M9: Added colorIdentity to extract mana colors for MDFC lands + usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity'] available_cols = [col for col in usecols if col in df.columns] if not available_cols: return {} @@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: multi_df['type'] = multi_df['type'].fillna('').astype(str) multi_df['side'] = multi_df['side'].fillna('').astype(str) multi_df['text'] = multi_df['text'].fillna('').astype(str) - land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] + # M9: Check both type and backType for land faces + if 'backType' in multi_df.columns: + multi_df['backType'] = multi_df['backType'].fillna('').astype(str) + land_mask = ( + multi_df['type'].str.contains('land', case=False, na=False) | + multi_df['backType'].str.contains('land', case=False, na=False) + ) + land_rows = multi_df[land_mask] + else: + land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] if land_rows.empty: return {} mapping: Dict[str, Dict[str, Any]] = {} @@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: seen: set[tuple[str, str, str]] = set() front_is_land = False layout_val = '' + + # M9: Handle merged rows with backType + if len(group) == 1 and 'backType' in group.columns: + row = group.iloc[0] + back_type_val = str(row.get('backType', '') or '') + if back_type_val and 'land' in back_type_val.lower(): + # Construct synthetic faces from merged row + front_type = str(row.get('type', '') or '') + front_text = str(row.get('text', '') or '') + mana_cost_val = str(row.get('manaCost', '') or '') + mana_value_raw = row.get('manaValue', '') + mana_value_val = None + try: + if mana_value_raw not in (None, ''): + mana_value_val = float(mana_value_raw) + if math.isnan(mana_value_val): + mana_value_val = None + except Exception: + mana_value_val = None + + # Front face + faces.append({ + 'face': str(row.get('faceName', '') or name), + 'side': 'a', + 'type': front_type, + 'text': front_text, + 'mana_cost': mana_cost_val, + 'mana_value': mana_value_val, + 'produces_mana': _detect_produces_mana(front_text), + 'is_land': 'land' in front_type.lower(), + 'layout': str(row.get('layout', '') or ''), + }) + + # Back face (synthesized) + # M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line) + color_identity_raw = row.get('colorIdentity', []) + if isinstance(color_identity_raw, str): + # Handle string format like "['G']" or "G" + try: + import ast + color_identity_raw = ast.literal_eval(color_identity_raw) + except Exception: + color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()] + back_face_colors = list(color_identity_raw) if color_identity_raw else [] + # Fallback to parsing land type if colorIdentity not available + if not back_face_colors: + back_face_colors = _extract_colors_from_land_type(back_type_val) + + faces.append({ + 'face': name.split(' // ')[1] if ' // ' in name else 'Back', + 'side': 'b', + 'type': back_type_val, + 'text': '', # Not available in merged row + 'mana_cost': '', + 'mana_value': None, + 'produces_mana': True, # Assume land produces mana + 'is_land': True, + 'layout': str(row.get('layout', '') or ''), + 'colors': back_face_colors, # M9: Color information for mana sources + }) + + front_is_land = 'land' in front_type.lower() + layout_val = str(row.get('layout', '') or '') + mapping[name] = { + 'faces': faces, + 'front_is_land': front_is_land, + 'layout': layout_val, + 'colors': back_face_colors, # M9: Store colors at top level for easy access + } + continue + + # Original logic for multi-row format for _, row in group.iterrows(): side_raw = str(row.get('side', '') or '').strip() side_key = side_raw.lower() @@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if hasattr(row, 'get'): row_type_raw = row.get('type', row.get('type_line', '')) or '' tline_full = str(row_type_raw).lower() + # M9: Check backType for MDFC land detection + back_type_raw = '' + if hasattr(row, 'get'): + back_type_raw = row.get('backType', '') or '' + back_type = str(back_type_raw).lower() # Land or permanent that could produce mana via text - is_land = ('land' in entry_type) or ('land' in tline_full) + is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type) base_is_land = is_land text_field_raw = '' if hasattr(row, 'get'): @@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if face_types or face_texts: is_land = True text_field = text_field_raw.lower().replace('\n', ' ') - # Skip obvious non-permanents (rituals etc.) + # Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land + # M9: If is_land is True (from backType check), we keep it regardless of front face type if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full): continue # Keep only candidates that are lands OR whose text indicates mana production @@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ colors['_dfc_land'] = True if not (base_is_land or dfc_entry.get('front_is_land')): colors['_dfc_counts_as_extra'] = True + # M9: Extract colors from DFC face metadata (back face land colors) + dfc_colors = dfc_entry.get('colors', []) + if dfc_colors: + for color in dfc_colors: + if color in colors: + colors[color] = 1 produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C')) if produces_any_color or colors.get('_dfc_land'): matrix[name] = colors diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py index f5808bc..3a752f6 100644 --- a/code/deck_builder/partner_selection.py +++ b/code/deck_builder/partner_selection.py @@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]: def _normalize_string_sequence(value: Any) -> tuple[str, ...]: if value is None: return tuple() - if isinstance(value, (list, tuple, set)): + # Handle numpy arrays, lists, tuples, sets, and other sequences + try: + import numpy as np + is_numpy = isinstance(value, np.ndarray) + except ImportError: + is_numpy = False + + if isinstance(value, (list, tuple, set)) or is_numpy: items = list(value) else: text = _safe_str(value) diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 97e691b..d0d05ab 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -543,6 +543,9 @@ class ReportingMixin: mf_info = {} faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else [] layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None + # M9: If no colors found from mana production, try extracting from face metadata + if not card_colors and isinstance(mf_info, dict): + card_colors = list(mf_info.get('colors', [])) dfc_land_lookup[name] = { 'adds_extra_land': counts_as_extra, 'counts_as_land': not counts_as_extra, @@ -681,13 +684,14 @@ class ReportingMixin: 'faces': faces_meta, 'layout': layout_val, }) - if adds_extra: - dfc_extra_total += copies + # M9: Count ALL MDFC lands for land summary + dfc_extra_total += copies total_sources = sum(source_counts.values()) traditional_lands = type_counts.get('Land', 0) + # M9: dfc_extra_total now contains ALL MDFC lands, not just extras land_summary = { 'traditional': traditional_lands, - 'dfc_lands': dfc_extra_total, + 'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands 'with_dfc': traditional_lands + dfc_extra_total, 'dfc_cards': dfc_details, 'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total), diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py new file mode 100644 index 0000000..08a7c22 --- /dev/null +++ b/code/file_setup/image_cache.py @@ -0,0 +1,567 @@ +""" +Card image caching system. + +Downloads and manages local cache of Magic: The Gathering card images +from Scryfall, with graceful fallback to API when images are missing. + +Features: +- Optional caching (disabled by default for open source users) +- Uses Scryfall bulk data API (respects rate limits and guidelines) +- Downloads from Scryfall CDN (no rate limits on image files) +- Progress tracking for long downloads +- Resume capability if interrupted +- Graceful fallback to API if images missing + +Environment Variables: + CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0) + +Image Sizes: + - small: 160px width (for list views) + - normal: 488px width (for prominent displays, hover previews) + +Directory Structure: + card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB) + card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB) + +See: https://scryfall.com/docs/api +""" + +import json +import logging +import os +import re +import time +from pathlib import Path +from typing import Any, Optional +from urllib.request import Request, urlopen + +from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient + +logger = logging.getLogger(__name__) + +# Scryfall CDN has no rate limits, but we'll be conservative +DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec) + +# Image sizes to cache +IMAGE_SIZES = ["small", "normal"] + +# Card name sanitization (filesystem-safe) +INVALID_CHARS = r'[<>:"/\\|?*]' + + +def sanitize_filename(card_name: str) -> str: + """ + Sanitize card name for use as filename. + + Args: + card_name: Original card name + + Returns: + Filesystem-safe filename + """ + # Replace invalid characters with underscore + safe_name = re.sub(INVALID_CHARS, "_", card_name) + # Remove multiple consecutive underscores + safe_name = re.sub(r"_+", "_", safe_name) + # Trim leading/trailing underscores + safe_name = safe_name.strip("_") + return safe_name + + +class ImageCache: + """Manages local card image cache.""" + + def __init__( + self, + base_dir: str = "card_files/images", + bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json", + ): + """ + Initialize image cache. + + Args: + base_dir: Base directory for cached images + bulk_data_path: Path to Scryfall bulk data JSON + """ + self.base_dir = Path(base_dir) + self.bulk_data_path = Path(bulk_data_path) + self.client = ScryfallBulkDataClient() + self._last_download_time: float = 0.0 + + def is_enabled(self) -> bool: + """Check if image caching is enabled via environment variable.""" + return os.getenv("CACHE_CARD_IMAGES", "0") == "1" + + def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]: + """ + Get local path to cached image if it exists. + + Args: + card_name: Card name + size: Image size ('small' or 'normal') + + Returns: + Path to cached image, or None if not cached + """ + if not self.is_enabled(): + return None + + safe_name = sanitize_filename(card_name) + image_path = self.base_dir / size / f"{safe_name}.jpg" + + if image_path.exists(): + return image_path + return None + + def get_image_url(self, card_name: str, size: str = "normal") -> str: + """ + Get image URL (local path if cached, Scryfall API otherwise). + + Args: + card_name: Card name + size: Image size ('small' or 'normal') + + Returns: + URL or local path to image + """ + # Check local cache first + local_path = self.get_image_path(card_name, size) + if local_path: + # Return as static file path for web serving + return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg" + + # Fallback to Scryfall API + from urllib.parse import quote + card_query = quote(card_name) + return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}" + + def _rate_limit_wait(self) -> None: + """Wait to respect rate limits between downloads.""" + elapsed = time.time() - self._last_download_time + if elapsed < DOWNLOAD_DELAY: + time.sleep(DOWNLOAD_DELAY - elapsed) + self._last_download_time = time.time() + + def _download_image(self, image_url: str, output_path: Path) -> bool: + """ + Download single image from Scryfall CDN. + + Args: + image_url: Image URL from bulk data + output_path: Local path to save image + + Returns: + True if successful, False otherwise + """ + self._rate_limit_wait() + + try: + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + req = Request(image_url) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + + with urlopen(req, timeout=30) as response: + image_data = response.read() + with open(output_path, "wb") as f: + f.write(image_data) + + return True + + except Exception as e: + logger.debug(f"Failed to download {image_url}: {e}") + # Clean up partial download + if output_path.exists(): + output_path.unlink() + return False + + def _load_bulk_data(self) -> list[dict[str, Any]]: + """ + Load card data from bulk data JSON. + + Returns: + List of card objects with image URLs + + Raises: + FileNotFoundError: If bulk data file doesn't exist + json.JSONDecodeError: If file is invalid JSON + """ + if not self.bulk_data_path.exists(): + raise FileNotFoundError( + f"Bulk data file not found: {self.bulk_data_path}. " + "Run download_bulk_data() first." + ) + + logger.info(f"Loading bulk data from {self.bulk_data_path}") + with open(self.bulk_data_path, "r", encoding="utf-8") as f: + return json.load(f) + + def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Filter bulk data to only cards in our all_cards.parquet file. + Deduplicates by card name (takes first printing only). + + Args: + bulk_cards: Full Scryfall bulk data + + Returns: + Filtered list of cards matching our dataset (one per unique name) + """ + try: + import pandas as pd + from code.path_util import get_processed_cards_path + + # Load our card names + parquet_path = get_processed_cards_path() + df = pd.read_parquet(parquet_path, columns=["name"]) + our_card_names = set(df["name"].str.lower()) + + logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset") + + # Filter and deduplicate - keep only first printing of each card + seen_names = set() + filtered = [] + + for card in bulk_cards: + card_name_lower = card.get("name", "").lower() + if card_name_lower in our_card_names and card_name_lower not in seen_names: + filtered.append(card) + seen_names.add(card_name_lower) + + logger.info(f"Filtered to {len(filtered)} unique cards with image data") + return filtered + + except Exception as e: + logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.") + return bulk_cards + + def download_bulk_data(self, progress_callback=None) -> None: + """ + Download latest Scryfall bulk data JSON. + + Args: + progress_callback: Optional callback(bytes_downloaded, total_bytes) + + Raises: + Exception: If download fails + """ + logger.info("Downloading Scryfall bulk data...") + self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True) + self.client.get_bulk_data( + output_path=str(self.bulk_data_path), + progress_callback=progress_callback, + ) + logger.info("Bulk data download complete") + + def download_images( + self, + sizes: Optional[list[str]] = None, + progress_callback=None, + max_cards: Optional[int] = None, + ) -> dict[str, int]: + """ + Download card images from Scryfall CDN. + + Args: + sizes: Image sizes to download (default: ['small', 'normal']) + progress_callback: Optional callback(current, total, card_name) + max_cards: Maximum cards to download (for testing) + + Returns: + Dictionary with download statistics + + Raises: + FileNotFoundError: If bulk data not available + """ + if not self.is_enabled(): + logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)") + return {"skipped": 0} + + if sizes is None: + sizes = IMAGE_SIZES + + logger.info(f"Starting image download for sizes: {sizes}") + + # Load bulk data and filter to our cards + bulk_cards = self._load_bulk_data() + cards = self._filter_to_our_cards(bulk_cards) + total_cards = len(cards) if max_cards is None else min(max_cards, len(cards)) + + stats = { + "total": total_cards, + "downloaded": 0, + "skipped": 0, + "failed": 0, + } + + for i, card in enumerate(cards[:total_cards]): + card_name = card.get("name") + if not card_name: + stats["skipped"] += 1 + continue + + # Collect all faces to download (single-faced or multi-faced) + faces_to_download = [] + + # Check if card has direct image_uris (single-faced card) + if card.get("image_uris"): + faces_to_download.append({ + "name": card_name, + "image_uris": card["image_uris"], + }) + # Handle double-faced cards (get all faces) + elif card.get("card_faces"): + for face_idx, face in enumerate(card["card_faces"]): + if face.get("image_uris"): + # For multi-faced cards, append face name or index + face_name = face.get("name", f"{card_name}_face{face_idx}") + faces_to_download.append({ + "name": face_name, + "image_uris": face["image_uris"], + }) + + # Skip if no faces found + if not faces_to_download: + logger.debug(f"No image URIs for {card_name}") + stats["skipped"] += 1 + continue + + # Download each face in each requested size + for face in faces_to_download: + face_name = face["name"] + image_uris = face["image_uris"] + + for size in sizes: + image_url = image_uris.get(size) + if not image_url: + continue + + # Check if already cached + safe_name = sanitize_filename(face_name) + output_path = self.base_dir / size / f"{safe_name}.jpg" + + if output_path.exists(): + stats["skipped"] += 1 + continue + + # Download image + if self._download_image(image_url, output_path): + stats["downloaded"] += 1 + else: + stats["failed"] += 1 + + # Progress callback + if progress_callback: + progress_callback(i + 1, total_cards, card_name) + + # Invalidate cached summary since we just downloaded new images + self.invalidate_summary_cache() + + logger.info(f"Image download complete: {stats}") + return stats + + def cache_statistics(self) -> dict[str, Any]: + """ + Get statistics about cached images. + + Uses a cached summary.json file to avoid scanning thousands of files. + Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS, + default 7 days, matching the main card data staleness check). + + Returns: + Dictionary with cache stats (count, size, etc.) + """ + stats = {"enabled": self.is_enabled()} + + if not self.is_enabled(): + return stats + + summary_file = self.base_dir / "summary.json" + + # Get staleness threshold from environment (same as card data check) + try: + refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7')) + except Exception: + refresh_days = 7 + + if refresh_days <= 0: + # Never consider stale + refresh_seconds = float('inf') + else: + refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds + + # Check if summary exists and is recent (less than refresh_seconds old) + use_cached = False + if summary_file.exists(): + try: + import time + file_age = time.time() - summary_file.stat().st_mtime + if file_age < refresh_seconds: + use_cached = True + except Exception: + pass + + # Try to use cached summary + if use_cached: + try: + import json + with summary_file.open('r', encoding='utf-8') as f: + cached_stats = json.load(f) + stats.update(cached_stats) + return stats + except Exception as e: + logger.warning(f"Could not read cache summary: {e}") + + # Regenerate summary (fast - just count files and estimate size) + for size in IMAGE_SIZES: + size_dir = self.base_dir / size + if size_dir.exists(): + # Fast count: count .jpg files without statting each one + count = sum(1 for _ in size_dir.glob("*.jpg")) + + # Estimate total size based on typical averages to avoid stat() calls + # Small images: ~40 KB avg, Normal images: ~100 KB avg + avg_size_kb = 40 if size == "small" else 100 + estimated_size_mb = (count * avg_size_kb) / 1024 + + stats[size] = { + "count": count, + "size_mb": round(estimated_size_mb, 1), + } + else: + stats[size] = {"count": 0, "size_mb": 0.0} + + # Save summary for next time + try: + import json + with summary_file.open('w', encoding='utf-8') as f: + json.dump({k: v for k, v in stats.items() if k != "enabled"}, f) + except Exception as e: + logger.warning(f"Could not write cache summary: {e}") + + return stats + + def invalidate_summary_cache(self) -> None: + """Delete the cached summary file to force regeneration on next call.""" + if not self.is_enabled(): + return + + summary_file = self.base_dir / "summary.json" + if summary_file.exists(): + try: + summary_file.unlink() + logger.debug("Invalidated cache summary file") + except Exception as e: + logger.warning(f"Could not delete cache summary: {e}") + + +def main(): + """CLI entry point for image caching.""" + import argparse + + parser = argparse.ArgumentParser(description="Card image cache management") + parser.add_argument( + "--download", + action="store_true", + help="Download images from Scryfall", + ) + parser.add_argument( + "--stats", + action="store_true", + help="Show cache statistics", + ) + parser.add_argument( + "--max-cards", + type=int, + help="Maximum cards to download (for testing)", + ) + parser.add_argument( + "--sizes", + nargs="+", + default=IMAGE_SIZES, + choices=IMAGE_SIZES, + help="Image sizes to download", + ) + parser.add_argument( + "--force", + action="store_true", + help="Force re-download of bulk data even if recent", + ) + + args = parser.parse_args() + + # Setup logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + cache = ImageCache() + + if args.stats: + stats = cache.cache_statistics() + print("\nCache Statistics:") + print(f" Enabled: {stats['enabled']}") + if stats["enabled"]: + for size in IMAGE_SIZES: + if size in stats: + print( + f" {size.capitalize()}: {stats[size]['count']} images " + f"({stats[size]['size_mb']:.1f} MB)" + ) + + elif args.download: + if not cache.is_enabled(): + print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.") + return + + # Check if bulk data already exists and is recent (within 24 hours) + bulk_data_exists = cache.bulk_data_path.exists() + bulk_data_age_hours = None + + if bulk_data_exists: + import time + age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime + bulk_data_age_hours = age_seconds / 3600 + print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)") + + # Download bulk data if missing, old, or forced + if not bulk_data_exists or bulk_data_age_hours > 24 or args.force: + print("Downloading Scryfall bulk data...") + + def bulk_progress(downloaded, total): + if total > 0: + pct = (downloaded / total) * 100 + print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / " + f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r") + + cache.download_bulk_data(progress_callback=bulk_progress) + print("\nBulk data downloaded successfully") + else: + print("Bulk data is recent, skipping download (use --force to re-download)") + + # Download images + print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...") + + def image_progress(current, total, card_name): + pct = (current / total) * 100 + print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r") + + stats = cache.download_images( + sizes=args.sizes, + progress_callback=image_progress, + max_cards=args.max_cards, + ) + print("\n\nDownload complete:") + print(f" Total: {stats['total']}") + print(f" Downloaded: {stats['downloaded']}") + print(f" Skipped: {stats['skipped']}") + print(f" Failed: {stats['failed']}") + + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/code/file_setup/scryfall_bulk_data.py b/code/file_setup/scryfall_bulk_data.py new file mode 100644 index 0000000..fd41d90 --- /dev/null +++ b/code/file_setup/scryfall_bulk_data.py @@ -0,0 +1,169 @@ +""" +Scryfall Bulk Data API client. + +Fetches bulk data JSON files from Scryfall's bulk data API, which provides +all card information including image URLs without hitting rate limits. + +See: https://scryfall.com/docs/api/bulk-data +""" + +import logging +import os +import time +from typing import Any +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data" +DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database +RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines) + + +class ScryfallBulkDataClient: + """Client for fetching Scryfall bulk data.""" + + def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY): + """ + Initialize Scryfall bulk data client. + + Args: + rate_limit_delay: Seconds to wait between API requests (default 100ms) + """ + self.rate_limit_delay = rate_limit_delay + self._last_request_time: float = 0.0 + + def _rate_limit_wait(self) -> None: + """Wait to respect rate limits between API calls.""" + elapsed = time.time() - self._last_request_time + if elapsed < self.rate_limit_delay: + time.sleep(self.rate_limit_delay - elapsed) + self._last_request_time = time.time() + + def _make_request(self, url: str) -> Any: + """ + Make HTTP request with rate limiting and error handling. + + Args: + url: URL to fetch + + Returns: + Parsed JSON response + + Raises: + Exception: If request fails after retries + """ + self._rate_limit_wait() + + try: + req = Request(url) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + with urlopen(req, timeout=30) as response: + import json + return json.loads(response.read().decode("utf-8")) + except Exception as e: + logger.error(f"Failed to fetch {url}: {e}") + raise + + def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]: + """ + Get bulk data metadata (download URL, size, last updated). + + Args: + bulk_type: Type of bulk data to fetch (default: default_cards) + + Returns: + Dictionary with bulk data info including 'download_uri' + + Raises: + ValueError: If bulk_type not found + Exception: If API request fails + """ + logger.info(f"Fetching bulk data info for type: {bulk_type}") + response = self._make_request(BULK_DATA_API_URL) + + # Find the requested bulk data type + for item in response.get("data", []): + if item.get("type") == bulk_type: + logger.info( + f"Found bulk data: {item.get('name')} " + f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, " + f"updated: {item.get('updated_at', 'unknown')})" + ) + return item + + raise ValueError(f"Bulk data type '{bulk_type}' not found") + + def download_bulk_data( + self, download_uri: str, output_path: str, progress_callback=None + ) -> None: + """ + Download bulk data JSON file. + + Args: + download_uri: Direct download URL from get_bulk_data_info() + output_path: Local path to save the JSON file + progress_callback: Optional callback(bytes_downloaded, total_bytes) + + Raises: + Exception: If download fails + """ + logger.info(f"Downloading bulk data from: {download_uri}") + logger.info(f"Saving to: {output_path}") + + # No rate limit on bulk data downloads per Scryfall docs + try: + req = Request(download_uri) + req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + + with urlopen(req, timeout=60) as response: + total_size = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + chunk_size = 1024 * 1024 # 1MB chunks + + # Ensure output directory exists + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if progress_callback: + progress_callback(downloaded, total_size) + + logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully") + + except Exception as e: + logger.error(f"Failed to download bulk data: {e}") + # Clean up partial download + if os.path.exists(output_path): + os.remove(output_path) + raise + + def get_bulk_data( + self, + bulk_type: str = DEFAULT_BULK_TYPE, + output_path: str = "card_files/raw/scryfall_bulk_data.json", + progress_callback=None, + ) -> str: + """ + Fetch bulk data info and download the JSON file. + + Args: + bulk_type: Type of bulk data to fetch + output_path: Where to save the JSON file + progress_callback: Optional progress callback + + Returns: + Path to downloaded file + + Raises: + Exception: If fetch or download fails + """ + info = self.get_bulk_data_info(bulk_type) + download_uri = info["download_uri"] + self.download_bulk_data(download_uri, output_path, progress_callback) + return output_path diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index 0b01e21..62a8165 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -349,6 +349,44 @@ def initial_setup() -> None: logger.info(f" Raw: {raw_path}") logger.info(f" Processed: {processed_path}") logger.info("=" * 80) + + # Step 3: Optional image caching (if enabled) + try: + from code.file_setup.image_cache import ImageCache + cache = ImageCache() + + if cache.is_enabled(): + logger.info("=" * 80) + logger.info("Card image caching enabled - starting download") + logger.info("=" * 80) + + # Download bulk data + logger.info("Downloading Scryfall bulk data...") + cache.download_bulk_data() + + # Download images + logger.info("Downloading card images (this may take 1-2 hours)...") + + def progress(current, total, card_name): + if current % 100 == 0: # Log every 100 cards + pct = (current / total) * 100 + logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}") + + stats = cache.download_images(progress_callback=progress) + + logger.info("=" * 80) + logger.info("✓ Image cache complete") + logger.info(f" Downloaded: {stats['downloaded']}") + logger.info(f" Skipped: {stats['skipped']}") + logger.info(f" Failed: {stats['failed']}") + logger.info("=" * 80) + else: + logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)") + logger.info("Images will be fetched from Scryfall API on demand") + + except Exception as e: + logger.error(f"Failed to cache images (continuing anyway): {e}") + logger.error("Images will be fetched from Scryfall API on demand") def regenerate_processed_parquet() -> None: diff --git a/code/tagging/multi_face_merger.py b/code/tagging/multi_face_merger.py index 0dd2753..deb31ac 100644 --- a/code/tagging/multi_face_merger.py +++ b/code/tagging/multi_face_merger.py @@ -240,6 +240,13 @@ def merge_multi_face_rows( faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()] + # M9: Capture back face type for MDFC land detection + if len(group_sorted) >= 2 and "type" in group_sorted.columns: + back_face_row = group_sorted.iloc[1] + back_type = str(back_face_row.get("type", "") or "") + if back_type: + work_df.at[primary_idx, "backType"] = back_type + drop_indices.extend(group_sorted.index[1:]) merged_count += 1 diff --git a/code/web/app.py b/code/web/app.py index ac2854b..7dd47b9 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -23,6 +23,9 @@ from .services.theme_catalog_loader import prewarm_common_filters, load_index # from .services.commander_catalog_loader import load_commander_catalog # type: ignore from .services.tasks import get_session, new_sid, set_session_value # type: ignore +# Logger for app-level logging +logger = logging.getLogger(__name__) + # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent _TEMPLATES_DIR = _THIS_DIR / "templates" @@ -99,6 +102,32 @@ if _STATIC_DIR.exists(): # Jinja templates templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) +# Add custom Jinja2 filter for card image URLs +def card_image_url(card_name: str, size: str = "normal") -> str: + """ + Generate card image URL (uses local cache if available, falls back to Scryfall). + + For DFC cards (containing ' // '), extracts the front face name. + + Args: + card_name: Name of the card (may be "Front // Back" for DFCs) + size: Image size ('small' or 'normal') + + Returns: + URL for the card image + """ + from urllib.parse import quote + + # Extract front face name for DFCs (thumbnails always show front face) + display_name = card_name + if ' // ' in card_name: + display_name = card_name.split(' // ')[0].strip() + + # Use our API endpoint which handles cache lookup and fallback + return f"/api/images/{size}/{quote(display_name)}" + +templates.env.filters["card_image"] = card_image_url + # Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...}) # and reorder to the new signature TemplateResponse(request, name, {...}). # Prevents DeprecationWarning noise in tests without touching all call sites. @@ -840,6 +869,12 @@ async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")}) +@app.get("/docs/components", response_class=HTMLResponse) +async def components_library(request: Request) -> HTMLResponse: + """M2 Component Library - showcase of standardized UI components""" + return templates.TemplateResponse("docs/components.html", {"request": request}) + + # Simple health check (hardened) @app.get("/healthz") async def healthz(): @@ -2212,6 +2247,13 @@ async def setup_status(): return JSONResponse({"running": False, "phase": "error"}) +# ============================================================================ +# Card Image Serving Endpoint - MOVED TO /routes/api.py +# ============================================================================ +# Image serving logic has been moved to code/web/routes/api.py +# The router is included below via: app.include_router(api_routes.router) + + # Routers from .routes import build as build_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402 @@ -2225,6 +2267,7 @@ from .routes import telemetry as telemetry_routes # noqa: E402 from .routes import cards as cards_routes # noqa: E402 from .routes import card_browser as card_browser_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402 +from .routes import api as api_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2237,6 +2280,7 @@ app.include_router(telemetry_routes.router) app.include_router(cards_routes.router) app.include_router(card_browser_routes.router) app.include_router(compare_routes.router) +app.include_router(api_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/api.py b/code/web/routes/api.py new file mode 100644 index 0000000..157344b --- /dev/null +++ b/code/web/routes/api.py @@ -0,0 +1,299 @@ +"""API endpoints for web services.""" + +from __future__ import annotations + +import logging +import threading +from pathlib import Path +from urllib.parse import quote_plus + +from fastapi import APIRouter, Query +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse + +from code.file_setup.image_cache import ImageCache + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + +# Global image cache instance +_image_cache = ImageCache() + + +@router.get("/images/status") +async def get_download_status(): + """ + Get current image download status. + + Returns: + JSON response with download status + """ + import json + + status_file = Path("card_files/images/.download_status.json") + + if not status_file.exists(): + # Check cache statistics if no download in progress + stats = _image_cache.cache_statistics() + return JSONResponse({ + "running": False, + "stats": stats + }) + + try: + with status_file.open('r', encoding='utf-8') as f: + status = json.load(f) + return JSONResponse(status) + except Exception as e: + logger.warning(f"Could not read status file: {e}") + return JSONResponse({ + "running": False, + "error": str(e) + }) + + +@router.get("/images/debug") +async def get_image_debug(): + """ + Debug endpoint to check image cache configuration. + + Returns: + JSON with debug information + """ + import os + from pathlib import Path + + base_dir = Path(_image_cache.base_dir) + + debug_info = { + "cache_enabled": _image_cache.is_enabled(), + "env_var": os.getenv("CACHE_CARD_IMAGES", "not set"), + "base_dir": str(base_dir), + "base_dir_exists": base_dir.exists(), + "small_dir": str(base_dir / "small"), + "small_dir_exists": (base_dir / "small").exists(), + "normal_dir": str(base_dir / "normal"), + "normal_dir_exists": (base_dir / "normal").exists(), + } + + # Count files if directories exist + if (base_dir / "small").exists(): + debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg"))) + if (base_dir / "normal").exists(): + debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg"))) + + # Test with a sample card name + test_card = "Lightning Bolt" + debug_info["test_card"] = test_card + test_path_small = _image_cache.get_image_path(test_card, "small") + test_path_normal = _image_cache.get_image_path(test_card, "normal") + debug_info["test_path_small"] = str(test_path_small) if test_path_small else None + debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None + debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False + debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False + + return JSONResponse(debug_info) + + +@router.get("/images/{size}/{card_name}") +async def get_card_image(size: str, card_name: str, face: str = Query(default="front")): + """ + Serve card image from cache or redirect to Scryfall API. + + Args: + size: Image size ('small' or 'normal') + card_name: Name of the card + face: Which face to show ('front' or 'back') for DFC cards + + Returns: + FileResponse if cached locally, RedirectResponse to Scryfall API otherwise + """ + # Validate size parameter + if size not in ["small", "normal"]: + size = "normal" + + # Check if caching is enabled + cache_enabled = _image_cache.is_enabled() + + # Check if image exists in cache + if cache_enabled: + image_path = None + + # For DFC cards, handle front/back faces differently + if " // " in card_name: + if face == "back": + # For back face, ONLY try the back face name + back_face = card_name.split(" // ")[1].strip() + logger.debug(f"DFC back face requested: {back_face}") + image_path = _image_cache.get_image_path(back_face, size) + else: + # For front face (or unspecified), try front face name + front_face = card_name.split(" // ")[0].strip() + logger.debug(f"DFC front face requested: {front_face}") + image_path = _image_cache.get_image_path(front_face, size) + else: + # Single-faced card, try exact name + image_path = _image_cache.get_image_path(card_name, size) + + if image_path and image_path.exists(): + logger.info(f"Serving cached image: {card_name} ({size}, {face})") + return FileResponse( + image_path, + media_type="image/jpeg", + headers={ + "Cache-Control": "public, max-age=31536000", # 1 year + } + ) + else: + logger.debug(f"No cached image found for: {card_name} (face: {face})") + + # Fallback to Scryfall API + # For back face requests of DFC cards, we need the full card name + scryfall_card_name = card_name + scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}" + + # If this is a back face request, try to find the full DFC name + if face == "back": + try: + from code.services.all_cards_loader import AllCardsLoader + loader = AllCardsLoader() + df = loader.load() + + # Look for cards where this face name appears in the card_faces + # The card name format is "Front // Back" + matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)] + if not matching.empty: + # Find DFC cards (containing ' // ') + dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)] + if not dfc_matches.empty: + # Use the first matching DFC card's full name + full_name = dfc_matches.iloc[0]['name'] + scryfall_card_name = full_name + # Add face parameter to Scryfall request + scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back" + except Exception as e: + logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}") + + scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}" + return RedirectResponse(scryfall_url) + + +@router.post("/images/download") +async def download_images(): + """ + Start downloading card images in background. + + Returns: + JSON response with status + """ + if not _image_cache.is_enabled(): + return JSONResponse({ + "ok": False, + "message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable." + }, status_code=400) + + # Write initial status + try: + status_dir = Path("card_files/images") + status_dir.mkdir(parents=True, exist_ok=True) + status_file = status_dir / ".download_status.json" + + import json + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "bulk_data", + "message": "Downloading Scryfall bulk data...", + "current": 0, + "total": 0, + "percentage": 0 + }, f) + except Exception as e: + logger.warning(f"Could not write initial status: {e}") + + # Start download in background thread + def _download_task(): + import json + status_file = Path("card_files/images/.download_status.json") + + try: + # Download bulk data first + logger.info("[IMAGE DOWNLOAD] Starting bulk data download...") + + def bulk_progress(downloaded: int, total: int): + """Progress callback for bulk data download.""" + try: + percentage = int(downloaded / total * 100) if total > 0 else 0 + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "bulk_data", + "message": f"Downloading bulk data: {percentage}%", + "current": downloaded, + "total": total, + "percentage": percentage + }, f) + except Exception as e: + logger.warning(f"Could not update bulk progress: {e}") + + _image_cache.download_bulk_data(progress_callback=bulk_progress) + + # Download images + logger.info("[IMAGE DOWNLOAD] Starting image downloads...") + + def image_progress(current: int, total: int, card_name: str): + """Progress callback for image downloads.""" + try: + percentage = int(current / total * 100) if total > 0 else 0 + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": True, + "phase": "images", + "message": f"Downloading images: {card_name}", + "current": current, + "total": total, + "percentage": percentage + }, f) + + # Log progress every 100 cards + if current % 100 == 0: + logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)") + + except Exception as e: + logger.warning(f"Could not update image progress: {e}") + + stats = _image_cache.download_images(progress_callback=image_progress) + + # Write completion status + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": False, + "phase": "complete", + "message": f"Download complete: {stats.get('downloaded', 0)} new images", + "stats": stats, + "percentage": 100 + }, f) + + logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}") + + except Exception as e: + logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True) + try: + with status_file.open('w', encoding='utf-8') as f: + json.dump({ + "running": False, + "phase": "error", + "message": f"Download failed: {str(e)}", + "percentage": 0 + }, f) + except Exception: + pass + + # Start background thread + thread = threading.Thread(target=_download_task, daemon=True) + thread.start() + + return JSONResponse({ + "ok": True, + "message": "Image download started in background" + }, status_code=202) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 5a80829..18e01c3 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -25,6 +25,7 @@ from ..services.build_utils import ( owned_set as owned_set_helper, builder_present_names, builder_display_map, + commander_hover_context, ) from ..app import templates from deck_builder import builder_constants as bc @@ -1349,6 +1350,14 @@ async def build_new_modal(request: Request) -> HTMLResponse: for key in skip_keys: sess.pop(key, None) + # M2: Clear commander and form selections for fresh start + commander_keys = [ + "commander", "partner", "background", "commander_mode", + "themes", "bracket" + ] + for key in commander_keys: + sess.pop(key, None) + theme_context = _custom_theme_context(request, sess) ctx = { "request": request, @@ -1483,20 +1492,14 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes merged_tags.append(token) ctx["tags"] = merged_tags + # Deduplicate recommended: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} existing_recommended = ctx.get("recommended") or [] - merged_recommended: list[str] = [] - rec_seen: set[str] = set() - for source in (partner_tags, existing_recommended): - for tag in source: - token = str(tag).strip() - if not token: - continue - key = token.casefold() - if key in rec_seen: - continue - rec_seen.add(key) - merged_recommended.append(token) - ctx["recommended"] = merged_recommended + deduplicated_recommended = [ + tag for tag in existing_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + ctx["recommended"] = deduplicated_recommended reason_map = dict(ctx.get("recommended_reasons") or {}) for tag in partner_tags: @@ -2907,6 +2910,11 @@ async def build_step2_get(request: Request) -> HTMLResponse: if is_gc and (sel_br is None or int(sel_br) < 3): sel_br = 3 partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) + + import logging + logger = logging.getLogger(__name__) + logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") + context = { "request": request, "commander": {"name": commander}, @@ -2940,7 +2948,22 @@ async def build_step2_get(request: Request) -> HTMLResponse: ) partner_tags = context.pop("partner_theme_tags", None) if partner_tags: + import logging + logger = logging.getLogger(__name__) context["tags"] = partner_tags + # Deduplicate recommended tags: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} + original_recommended = context.get("recommended", []) + deduplicated_recommended = [ + tag for tag in original_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + logger.info( + f"Step2: partner_tags={len(partner_tags)}, " + f"original_recommended={len(original_recommended)}, " + f"deduplicated_recommended={len(deduplicated_recommended)}" + ) + context["recommended"] = deduplicated_recommended resp = templates.TemplateResponse("build/_step2.html", context) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -3266,6 +3289,57 @@ async def build_step3_get(request: Request) -> HTMLResponse: sess["last_step"] = 3 defaults = orch.ideal_defaults() values = sess.get("ideals") or defaults + + # Check if any skip flags are enabled to show skeleton automation page + skip_flags = { + "skip_lands": "land selection", + "skip_to_misc": "land selection", + "skip_basics": "basic lands", + "skip_staples": "staple lands", + "skip_kindred": "kindred lands", + "skip_fetches": "fetch lands", + "skip_duals": "dual lands", + "skip_triomes": "triome lands", + "skip_all_creatures": "creature selection", + "skip_creature_primary": "primary creatures", + "skip_creature_secondary": "secondary creatures", + "skip_creature_fill": "creature fills", + "skip_all_spells": "spell selection", + "skip_ramp": "ramp spells", + "skip_removal": "removal spells", + "skip_wipes": "board wipes", + "skip_card_advantage": "card advantage spells", + "skip_protection": "protection spells", + "skip_spell_fill": "spell fills", + } + + active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)] + + if active_skips: + # Show skeleton automation page with auto-submit + automation_parts = [] + if any("land" in s for s in active_skips): + automation_parts.append("lands") + if any("creature" in s for s in active_skips): + automation_parts.append("creatures") + if any("spell" in s for s in active_skips): + automation_parts.append("spells") + + automation_message = f"Applying default values for {', '.join(automation_parts)}..." + + resp = templates.TemplateResponse( + "build/_step3_skeleton.html", + { + "request": request, + "defaults": defaults, + "commander": sess.get("commander"), + "automation_message": automation_message, + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # No skips enabled, show normal form resp = templates.TemplateResponse( "build/_step3.html", { @@ -3844,6 +3918,16 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe ctx["synergies"] = synergies ctx["summary_ready"] = True ctx["summary_token"] = active_token + + # Add commander hover context for color identity and theme tags + hover_meta = commander_hover_context( + commander_name=ctx.get("commander"), + deck_tags=sess.get("tags"), + summary=summary_data, + combined=ctx.get("combined_commander"), + ) + ctx.update(hover_meta) + response = templates.TemplateResponse("partials/deck_summary.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") return response diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index 9cbe635..f590c39 100644 --- a/code/web/routes/setup.py +++ b/code/web/routes/setup.py @@ -195,7 +195,11 @@ async def download_github(): @router.get("/", response_class=HTMLResponse) async def setup_index(request: Request) -> HTMLResponse: import code.settings as settings + from code.file_setup.image_cache import ImageCache + + image_cache = ImageCache() return templates.TemplateResponse("setup/index.html", { "request": request, - "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES + "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES, + "image_cache_enabled": image_cache.is_enabled() }) diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py index 32cb279..a4fb8b2 100644 --- a/code/web/routes/themes.py +++ b/code/web/routes/themes.py @@ -291,28 +291,6 @@ def _diag_enabled() -> bool: return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"} -@router.get("/picker", response_class=HTMLResponse) -async def theme_picker_page(request: Request): - """Render the theme picker shell. - - Dynamic data (list, detail) loads via fragment endpoints. We still inject - known archetype list for the filter select so it is populated on initial load. - """ - archetypes: list[str] = [] - try: - idx = load_index() - archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type] - except Exception: - archetypes = [] - return _templates.TemplateResponse( - "themes/picker.html", - { - "request": request, - "archetypes": archetypes, - "theme_picker_diagnostics": _diag_enabled(), - }, - ) - @router.get("/metrics") async def theme_metrics(): if not _diag_enabled(): @@ -746,89 +724,9 @@ async def api_theme_preview( return JSONResponse({"ok": True, "preview": payload}) -@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse) -async def theme_preview_fragment( - theme_id: str, - limit: int = Query(12, ge=1, le=30), - colors: str | None = None, - commander: str | None = None, - suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"), - minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale – used in detail page collapsible preview)"), - request: Request = None, -): - """Return HTML fragment for theme preview with caching headers. - Adds ETag and Last-Modified headers (no strong caching – enables conditional GET / 304). - ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander). - """ - try: - payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander) - except KeyError: - return HTMLResponse("
Theme not found.
", status_code=404) - # Load example commanders (authoritative list) from catalog detail for legality instead of inferring - example_commanders: list[str] = [] - synergy_commanders: list[str] = [] - try: - idx = load_index() - slug = slugify(theme_id) - entry = idx.slug_to_entry.get(slug) - if entry: - detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False) - example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)] - synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)] - # De-duplicate any overlap with example commanders while preserving order - seen = set(example_commanders) - for c in synergy_commanders_raw: - if c not in seen: - synergy_commanders.append(c) - seen.add(c) - except Exception: - example_commanders = [] - synergy_commanders = [] - # Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift) - import hashlib - import json as _json - import time as _time - try: - idx = load_index() - catalog_tag = idx.etag - except Exception: - catalog_tag = "unknown" - hash_src = _json.dumps({ - "theme": theme_id, - "limit": limit, - "commander": commander, - "sample": payload.get("sample", [])[:3], # small slice for stability & speed - "v": 1, - }, sort_keys=True).encode("utf-8") - etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}" - # Conditional request support - if request is not None: - inm = request.headers.get("if-none-match") - if inm and inm == etag: - # 304 Not Modified – FastAPI HTMLResponse with empty body & headers - resp = HTMLResponse(status_code=304, content="") - resp.headers["ETag"] = etag - from email.utils import formatdate as _fmtdate - resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) - resp.headers["Cache-Control"] = "no-cache" - return resp - ctx = { - "request": request, - "preview": payload, - "example_commanders": example_commanders, - "synergy_commanders": synergy_commanders, - "theme_id": theme_id, - "etag": etag, - "suppress_curated": suppress_curated, - "minimal": minimal, - } - resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx) - resp.headers["ETag"] = etag - from email.utils import formatdate as _fmtdate - resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) - resp.headers["Cache-Control"] = "no-cache" - return resp + +@router.get("/fragment/list", response_class=HTMLResponse) # --- Preview Export Endpoints (CSV / JSON) --- diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index a37a540..241a1c7 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -310,13 +310,30 @@ def commander_hover_context( raw_color_identity = combined_info.get("color_identity") if combined_info else None commander_color_identity: list[str] = [] + + # If we have a combined commander (partner/background), use its color identity if isinstance(raw_color_identity, (list, tuple, set)): for item in raw_color_identity: token = str(item).strip().upper() if token: commander_color_identity.append(token) - # M7: For non-partner commanders, also check summary.colors for color identity + # For regular commanders (no partner/background), look up from commander catalog first + if not commander_color_identity and not has_combined and commander_name: + try: + from .commander_catalog_loader import find_commander_record + record = find_commander_record(commander_name) + if record and hasattr(record, 'color_identity'): + raw_ci = record.color_identity + if isinstance(raw_ci, (list, tuple, set)): + for item in raw_ci: + token = str(item).strip().upper() + if token: + commander_color_identity.append(token) + except Exception: + pass + + # Fallback: check summary.colors if we still don't have color identity if not commander_color_identity and not has_combined and isinstance(summary, dict): summary_colors = summary.get("colors") if isinstance(summary_colors, (list, tuple, set)): diff --git a/code/web/static/components.js b/code/web/static/components.js new file mode 100644 index 0000000..de4021c --- /dev/null +++ b/code/web/static/components.js @@ -0,0 +1,375 @@ +/** + * M2 Component Library - JavaScript Utilities + * + * Core functions for interactive components: + * - Card flip button (dual-faced cards) + * - Collapsible panels + * - Card popups + * - Modal management + */ + +// ============================================ +// CARD FLIP FUNCTIONALITY +// ============================================ + +/** + * Flip a dual-faced card image between front and back faces + * @param {HTMLElement} button - The flip button element + */ +function flipCard(button) { + const container = button.closest('.card-thumb-container, .card-popup-image'); + if (!container) return; + + const img = container.querySelector('img'); + if (!img) return; + + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length < 2) return; + + // Determine current face (default to 0 = front) + const currentFace = parseInt(img.dataset.currentFace || '0', 10); + const nextFace = currentFace === 0 ? 1 : 0; + const faceName = faces[nextFace]; + + // Determine image version based on container + const isLarge = container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image'); + const version = isLarge ? 'normal' : 'small'; + + // Update image source + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; + img.alt = `${faceName} image`; + img.dataset.currentFace = nextFace.toString(); + + // Update button aria-label + const otherFace = faces[currentFace]; + button.setAttribute('aria-label', `Flip to ${otherFace}`); +} + +/** + * Reset all card images to show front face + * Useful when navigating between pages or clearing selections + */ +function resetCardFaces() { + document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { + const cardName = img.dataset.cardName; + const faces = cardName.split(' // '); + if (faces.length > 1) { + const frontFace = faces[0]; + const container = img.closest('.card-thumb-container, .card-popup-image'); + const isLarge = container && (container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image')); + const version = isLarge ? 'normal' : 'small'; + + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; + img.alt = `${frontFace} image`; + img.dataset.currentFace = '0'; + } + }); +} + +// ============================================ +// COLLAPSIBLE PANEL FUNCTIONALITY +// ============================================ + +/** + * Toggle a collapsible panel's expanded/collapsed state + * @param {string} panelId - The ID of the panel element + */ +function togglePanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + + // Toggle state + button.setAttribute('aria-expanded', (!isExpanded).toString()); + content.style.display = isExpanded ? 'none' : 'block'; + + // Toggle classes + panel.classList.toggle('panel-expanded', !isExpanded); + panel.classList.toggle('panel-collapsed', isExpanded); +} + +/** + * Expand a collapsible panel + * @param {string} panelId - The ID of the panel element + */ +function expandPanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'true'); + content.style.display = 'block'; + panel.classList.add('panel-expanded'); + panel.classList.remove('panel-collapsed'); +} + +/** + * Collapse a collapsible panel + * @param {string} panelId - The ID of the panel element + */ +function collapsePanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle'); + const content = panel.querySelector('.panel-collapse-content'); + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'false'); + content.style.display = 'none'; + panel.classList.add('panel-collapsed'); + panel.classList.remove('panel-expanded'); +} + +// ============================================ +// MODAL MANAGEMENT +// ============================================ + +/** + * Open a modal by ID + * @param {string} modalId - The ID of the modal element + */ +function openModal(modalId) { + const modal = document.getElementById(modalId); + if (!modal) return; + + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // Focus first focusable element in modal + const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusable) { + setTimeout(() => focusable.focus(), 100); + } +} + +/** + * Close a modal by ID or element + * @param {string|HTMLElement} modalOrId - Modal element or ID + */ +function closeModal(modalOrId) { + const modal = typeof modalOrId === 'string' + ? document.getElementById(modalOrId) + : modalOrId; + + if (!modal) return; + + modal.remove(); + + // Restore body scroll if no other modals are open + if (!document.querySelector('.modal')) { + document.body.style.overflow = ''; + } +} + +/** + * Close all open modals + */ +function closeAllModals() { + document.querySelectorAll('.modal').forEach(modal => modal.remove()); + document.body.style.overflow = ''; +} + +// ============================================ +// CARD POPUP FUNCTIONALITY +// ============================================ + +/** + * Show card details popup on hover or tap + * @param {string} cardName - The card name + * @param {Object} options - Popup options + * @param {string[]} options.tags - Card tags + * @param {string[]} options.highlightTags - Tags to highlight + * @param {string} options.role - Card role + * @param {string} options.layout - Card layout (for flip button) + */ +function showCardPopup(cardName, options = {}) { + // Remove any existing popup + closeCardPopup(); + + const { + tags = [], + highlightTags = [], + role = '', + layout = 'normal' + } = options; + + const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); + const baseName = cardName.split(' // ')[0]; + + // Create popup HTML + const popup = document.createElement('div'); + popup.className = 'card-popup'; + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-label', `${cardName} details`); + + let tagsHTML = ''; + if (tags.length > 0) { + tagsHTML = '
'; + 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/css_backup_pre_tailwind/styles.css b/code/web/static/css_backup_pre_tailwind/styles.css new file mode 100644 index 0000000..eda7352 --- /dev/null +++ b/code/web/static/css_backup_pre_tailwind/styles.css @@ -0,0 +1,1208 @@ +/* Base */ +:root{ + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; /* focus ring */ + --ok: #16a34a; /* success */ + --warn: #f59e0b; /* warning */ + --err: #ef4444; /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); +} + +/* Light blend between Slate and Parchment (leans gray) */ +[data-theme="light-blend"]{ + --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; /* slightly warm muted */ + --border: #d6d1c7; /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; +} + +[data-theme="dark"]{ + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; +} +[data-theme="high-contrast"]{ + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; +} +[data-theme="cb-friendly"]{ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ +} +*{box-sizing:border-box} +html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} +body { + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; +} +/* Honor HTML hidden attribute across the app */ +[hidden] { display: none !important; } +/* Accessible focus ring for keyboard navigation */ +.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* Top banner */ +.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } +.top-banner{ min-height: var(--banner-h); } +.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } +.top-banner .top-inner > div{ min-width:0; } +@media (max-width: 1100px){ + .top-banner .top-inner{ grid-auto-rows:auto; } + .top-banner .top-inner select{ max-width:140px; } +} +.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } +.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } +.banner-status.busy{ color:#fbbf24; } +.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } +.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } + +/* Layout */ +.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} +.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } + +/* Collapsible sidebar behavior */ +body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } +body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } +body.nav-collapsed .content{ grid-column: 2; } +body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } +body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } +/* Smooth hide/show on mobile while keeping fixed positioning */ +.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } +/* Suppress sidebar transitions during page load to prevent pop-in */ +body.no-transition .sidebar{ transition: none !important; } +/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ +body.htmx-settling .sidebar{ transition: none !important; } +body.htmx-settling .layout{ transition: none !important; } +body.htmx-settling .content{ transition: none !important; } +body.htmx-settling *{ transition-duration: 0s !important; } + +/* Mobile tweaks */ +@media (max-width: 900px){ + :root{ --sidebar-w: 240px; } + .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } + .banner-status{ padding-left: .5rem; } + .layout{ grid-template-columns: 0 1fr; } + .sidebar{ transform: translateX(-100%); visibility: hidden; } + body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } + body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } + .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } + .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } + /* Spacing tweaks: tighter left, larger gaps between visible items */ + .top-banner .top-inner > div{ gap: 25px !important; } + .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } + /* Mobile: show only Menu, Title, and Theme selector */ + #btn-open-permalink{ display:none !important; } + #banner-status{ display:none !important; } + #health-dot{ display:none !important; } + .top-banner #theme-reset{ display:none !important; } +} + +/* Additional mobile spacing for bottom floating controls */ +@media (max-width: 720px) { + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } +} + +.brand h1{ display:none; } +.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } +.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } +.dot.green{ background: var(--green-main); } +.dot.blue{ background: var(--blue-main); } +.dot.red{ background: var(--red-main); } +.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } +.dot.black{ background: var(--black-light); } + +.nav{ display:flex; flex-direction:column; gap:.35rem; } +.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } +.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } + +/* Sidebar theme controls anchored at bottom */ +.sidebar .nav { flex: 1 1 auto; } +.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } +.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } +.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } +.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } + +/* Simple two-column layout for inspect panel */ +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } +.two-col .grow { min-width: 0; } +.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } +@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +/* Left-rail variant puts the image first */ +.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } +/* Ensure left-rail variant also collapses to 1 column on small screens */ +@media (max-width: 900px){ + .two-col.two-col-left-rail{ grid-template-columns: 1fr; } + /* So the commander image doesn't dominate on mobile */ + .two-col .card-preview{ max-width: 360px; margin: 0 auto; } + .two-col .card-preview img{ width: 100%; height: auto; } +} +.card-preview.card-sm{ max-width:200px; } + +/* Buttons, inputs */ +button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } +button:hover{ filter:brightness(1.05); } +/* Anchor-style buttons */ +.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } +.btn:hover{ filter:brightness(1.05); text-decoration:none; } +.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } +label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } +select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } +fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } +small, .muted{ color: var(--muted); } +.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } +.partner-preview[hidden]{ display:none !important; } +.partner-preview__header{ font-weight:600; } +.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } +.partner-preview__art{ flex:0 0 auto; } +.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } +.partner-preview__details{ flex:1 1 180px; min-width:0; } +.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } +.partner-preview__pairing{ margin-top:.35rem; } +.partner-preview__themes{ margin-top:.35rem; font-size:12px; } +.partner-preview--static{ margin-bottom:.5rem; } +.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +/* Toasts */ +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } + +/* Skeletons */ +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } +[data-skeleton][data-skeleton-overlay="false"]::after, +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton]::after{ + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; +} +[data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } +@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } + +/* Banner */ +.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } +.banner h1{ font-size: 2rem; margin:0 0 .35rem; } +.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +/* Home actions */ +.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } +.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } +.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } +.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +/* Card grid for added cards (responsive, compact tiles) */ +.card-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; +} +@media (max-width: 420px){ + .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-tile{ width: 100%; } + .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } +} +.card-tile{ + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; +} +.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } +.card-tile.locked{ + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; +} +.card-tile.must-include{ + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); +} +.card-tile.must-exclude{ + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; +} +.card-tile.must-include.must-exclude{ + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} +.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } +.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } +.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } + +.must-have-controls{ + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; +} +.must-have-btn{ + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; +} +.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); +} +.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); +} +.must-have-btn:focus-visible{ + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; +} +.card-tile.must-exclude .must-have-btn.include[data-active="0"], +.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ + opacity:.65; +} + +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } + +/* Shared ownership badge for card tiles and stacked images */ +.owned-badge{ + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; +} + +/* Step 1 candidate grid (200px-wide scaled images) */ +.candidate-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; +} +.candidate-tile{ + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} +.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } +.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } +.candidate-tile .meta{ text-align:center; margin-top:.35rem; } +.candidate-tile .name{ font-weight:600; font-size:.95rem; } +.candidate-tile .score{ color:var(--muted); font-size:.85rem; } + +/* Deck summary: highlight game changers */ +.game-changer { color: var(--green-main); } +.stack-card.game-changer { outline: 2px solid var(--green-main); } + +/* Image button inside card tiles */ +.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +/* Stage Navigator */ +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } +.stage-nav .name { font-size:12px; } + +/* Build controls sticky box tweaks */ +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); +} + +@media (max-width: 1024px){ + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } +} +@media (min-width: 721px){ + :root { --banner-offset: 48px; } +} + +/* Progress bar */ +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +/* Chips */ +.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } +.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } + +/* Cards toolbar */ +.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } +.cards-toolbar input[type="text"]{ min-width: 220px; } +.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } +.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +/* Collapse groups and reason toggle */ +.group{ margin:.5rem 0; } +.group-header{ display:flex; align-items:center; gap:.5rem; } +.group-header h5{ margin:.4rem 0; } +.group-header .count{ color: var(--muted); font-size:12px; } +.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } +.group-grid[data-collapsed]{ display:none; } +.hide-reasons .card-tile .reason{ display:none; } +.card-tile.force-show .reason{ display:block !important; } +.card-tile.force-hide .reason{ display:none !important; } +.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } +.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } +.chips-inline .chip{ cursor:pointer; user-select:none; } + +/* Inline error banner */ +.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } +.inline-error-banner .muted{ color:#fda4af; } + +/* Alternatives panel */ +.alts ul{ list-style:none; padding:0; margin:0; } +.alts li{ display:flex; align-items:center; gap:.4rem; } +/* LQIP blur/fade-in for thumbnails */ +img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } +img.lqip.loaded { filter: blur(0); opacity: 1; } + +/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; } + img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } +} + +/* Virtualization wrapper should mirror grid to keep multi-column flow */ +.virt-wrapper { display: grid; } + +/* Mobile responsive fixes for horizontal scrolling issues */ +@media (max-width: 768px) { + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } + + /* Test hand responsive adjustments */ + #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + + /* Modal & form layout fixes (original block retained inside media query) */ + /* Fix modal layout on mobile */ + .modal { + padding: 10px !important; + box-sizing: border-box; + } + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + /* Force single column for include/exclude grid */ + .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } + /* Fix basics grid */ + .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } + /* Ensure all inputs and textareas fit properly */ + .modal input, + .modal textarea, + .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } + /* Fix chips containers */ + .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } + /* Ensure fieldsets don't overflow */ + .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } + /* Fix any inline styles that might cause overflow */ + .modal fieldset > div, + .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } +} + +@media (max-width: 640px){ + #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } + /* Generic stack shrink */ + .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } +} + +@media (max-width: 560px){ + #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } + #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } + #test-hand .stack-card{ flex:0 0 auto; } + .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } +} + +@media (max-width: 480px) { + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; /* Much smaller padding */ + font-size: 12px !important; /* Smaller font */ + min-height: 36px !important; /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + .build-controls .sep, + .build-controls .replace-toggle, + .build-controls label[style*="margin-left"] { + display: none !important; + } + + .build-controls .sep { + display: none !important; /* Hide separators on mobile */ + } +} + +/* Desktop sizing for Test Hand */ +@media (min-width: 900px) { + #test-hand { --card-w: 280px !important; --card-h: 392px !important; } +} + +/* Analytics accordion styling */ +.analytics-accordion { + transition: all 0.2s ease; +} + +.analytics-accordion summary { + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.analytics-accordion summary:hover { + background: #1f2937; + border-color: #374151; +} + +.analytics-accordion summary:active { + transform: scale(0.99); +} + +.analytics-accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.analytics-accordion .analytics-content { + animation: accordion-slide-down 0.3s ease-out; +} + +@keyframes accordion-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-placeholder .skeleton-pulse { + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Ideals Slider Styling */ +.ideals-slider { + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; +} + +.ideals-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.ideals-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.slider-value { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Card Details button on tiles */ +.card-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; +} + +.card-details-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); +} + +.card-details-btn svg { + flex-shrink: 0; +} + +/* Card Preview Modal */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.preview-modal.active { + display: flex; +} + +.preview-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.preview-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.preview-close { + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.preview-close:hover { + background: #fff; + transform: scale(1.1); +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} + +/* Card Detail Page Styles */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.card-tag { + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; +} + +.back-button:hover { + background: var(--ring); + color: white; + border-color: var(--ring); +} + +/* Card Detail Page - Main Card Image */ +.card-image-large { + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; +} + +.card-image-large img { + width: 100%; + height: auto; + border-radius: 12px; +} diff --git a/code/web/static/shared-components.css b/code/web/static/shared-components.css new file mode 100644 index 0000000..628dff0 --- /dev/null +++ b/code/web/static/shared-components.css @@ -0,0 +1,643 @@ +/* Shared Component Styles - Not processed by Tailwind PurgeCSS */ + +/* Card-style list items (used in theme catalog, commander browser, etc.) */ +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +/* Filter chips (used in theme catalog, card browser, etc.) */ +.filter-chip { + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 2px 8px; + border-radius: 14px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; +} + +.filter-chip-remove { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 0; + line-height: 1; +} + +/* Loading skeleton cards (used in theme catalog, deck lists, etc.) */ +.skeleton-card { + height: 48px; + border-radius: 8px; + background: linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%); + background-size: 200% 100%; + animation: sk 1.2s ease-in-out infinite; +} + +/* Search suggestion dropdowns (used in theme catalog, card search, etc.) */ +.search-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--panel); + border: 1px solid var(--border); + border-top: none; + z-index: 25; + display: none; + max-height: 300px; + overflow: auto; + border-radius: 0 0 8px 8px; +} + +.search-suggestions a { + display: block; + padding: 0.5rem 0.6rem; + font-size: 13px; + text-decoration: none; + color: var(--text); + border-bottom: 1px solid var(--border); + transition: background 0.15s ease; +} + +.search-suggestions a:last-child { + border-bottom: none; +} + +.search-suggestions a:hover, +.search-suggestions a.selected { + background: var(--hover); +} + +.search-suggestions a.selected { + border-left: 3px solid var(--ring); + padding-left: calc(0.6rem - 3px); +} + +/* Card reference links (clickable card names with hover preview) */ +.card-ref { + cursor: pointer; + text-decoration: underline dotted; +} + +.card-ref:hover { + color: var(--accent); +} + +/* Modal components (used in new deck modal, settings modals, etc.) */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + overflow: auto; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); +} + +.modal-content { + position: relative; + max-width: 720px; + width: clamp(320px, 90vw, 720px); + background: #0f1115; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-height: min(92vh, 100%); + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Form field components */ +.form-label { + display: block; + margin-bottom: 0.5rem; +} + +.form-checkbox-label { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 0.5rem; + margin: 0; + width: 100%; + cursor: pointer; + text-align: left; +} + +.form-checkbox-label input[type="checkbox"], +.form-checkbox-label input[type="radio"] { + margin: 0; + cursor: pointer; +} + +/* Include/Exclude card chips (green/red themed) */ +.include-chips-container { + margin-top: 0.5rem; + min-height: 30px; + border: 1px solid #4ade80; + border-radius: 6px; + padding: 0.5rem; + background: rgba(74, 222, 128, 0.05); + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: flex-start; +} + +.exclude-chips-container { + margin-top: 0.5rem; + min-height: 30px; + border: 1px solid #ef4444; + border-radius: 6px; + padding: 0.5rem; + background: rgba(239, 68, 68, 0.05); + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: flex-start; +} + +.chips-inner { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + flex: 1; +} + +.chips-placeholder { + color: #6b7280; + font-size: 11px; + font-style: italic; +} + +/* Card list textarea styling */ +.include-textarea { + width: 100%; + min-height: 60px; + resize: vertical; + font-family: monospace; + font-size: 12px; + border-left: 3px solid #4ade80; + color: #1f2937; + background: #ffffff; +} + +.include-textarea::placeholder { + color: #9ca3af; + opacity: 0.7; +} + +/* Alternative card buttons - force text wrapping */ +.alt-option { + display: block !important; + width: 100% !important; + max-width: 100% !important; + text-align: left !important; + white-space: normal !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + line-height: 1.3 !important; + padding: 0.5rem 0.7rem !important; +} + +.exclude-textarea { + width: 100%; + min-height: 60px; + resize: vertical; + font-family: monospace; + font-size: 12px; + border-left: 3px solid #ef4444; + color: #1f2937; + background: #ffffff; +} + +.exclude-textarea::placeholder { + color: #9ca3af; + opacity: 0.7; +} + +/* Info/warning panels */ +.info-panel { + margin-top: 0.75rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 6px; +} + +.info-panel summary { + cursor: pointer; + font-size: 12px; + color: #60a5fa; +} + +.info-panel-content { + margin-top: 0.5rem; + font-size: 12px; + line-height: 1.5; +} + +/* Include/Exclude card list helpers */ +.include-exclude-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 0.5rem; +} + +@media (max-width: 768px) { + .include-exclude-grid { + grid-template-columns: 1fr; + } +} + +.card-list-label { + display: block; + margin-bottom: 0.5rem; +} + +.card-list-label small { + color: #9ca3af; + opacity: 1; +} + +.card-list-label-include { + color: #4ade80; + font-weight: 500; +} + +.card-list-label-exclude { + color: #ef4444; + font-weight: 500; +} + +.card-list-controls { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 12px; +} + +.card-list-count { + font-size: 11px; +} + +.card-list-validation { + margin-top: 0.5rem; + font-size: 12px; +} + +.card-list-badges { + display: flex; + gap: 0.25rem; + font-size: 10px; +} + +/* Button variants for include/exclude controls */ +.btn-upload-include { + cursor: pointer; + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #065f46; + border-color: #059669; +} + +.btn-upload-exclude { + cursor: pointer; + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #7f1d1d; + border-color: #dc2626; +} + +.btn-clear { + font-size: 11px; + padding: 0.25rem 0.5rem; + background: #7f1d1d; + border-color: #dc2626; +} + +/* Modal footer */ +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: space-between; + margin-top: 1rem; +} + +.modal-footer-left { + display: flex; + gap: 0.5rem; +} + +/* Chip dot color variants */ +.dot-green { + background: var(--green-main); +} + +.dot-blue { + background: var(--blue-main); +} + +.dot-orange { + background: var(--orange-main, #f97316); +} + +.dot-red { + background: var(--red-main); +} + +.dot-purple { + background: var(--purple-main, #a855f7); +} + +/* Form label with icon */ +.form-label-icon { + display: flex; + align-items: center; + gap: 0.35rem; +} + +/* Inline form (for control buttons) */ +.inline-form { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* Locked cards list */ +.locked-list { + list-style: none; + padding: 0; + margin: 0.35rem 0 0; + display: grid; + gap: 0.35rem; +} + +.locked-item { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.lock-box-inline { + display: inline; + margin-left: auto; +} + +/* Build controls sticky section */ +.build-controls { + position: sticky; + z-index: 5; + background: linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.5rem; + margin-top: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +/* Alert box */ +.alert-error { + margin-top: 0.5rem; + color: #fecaca; + background: #7f1d1d; + border: 1px solid #991b1b; + padding: 0.5rem 0.75rem; + border-radius: 8px; +} + +/* Stage timeline list */ +.timeline-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.25rem; +} + +.timeline-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Card action buttons container */ +.card-actions-center { + display: flex; + justify-content: center; + margin-top: 0.25rem; + gap: 0.35rem; + flex-wrap: wrap; +} + +/* Ownership badge (small circular indicator) */ +.ownership-badge { + display: inline-block; + border: 1px solid var(--border); + background: rgba(17,24,39,.9); + color: #e5e7eb; + border-radius: 12px; + font-size: 12px; + line-height: 18px; + height: 18px; + min-width: 18px; + padding: 0 6px; + text-align: center; +} + +/* Build log pre formatting */ +.build-log { + margin-top: 0.5rem; + white-space: pre-wrap; + background: #0f1115; + border: 1px solid var(--border); + padding: 1rem; + border-radius: 8px; + max-height: 40vh; + overflow: auto; +} + +/* Last action status area (prevents layout shift) */ +.last-action { + min-height: 1.5rem; +} + +/* Deck summary section divider */ +.summary-divider { + margin: 1.25rem 0; + border-color: var(--border); +} + +/* Summary type heading */ +.summary-type-heading { + margin: 0.5rem 0 0.25rem 0; + font-weight: 600; +} + +/* Summary view controls */ +.summary-view-controls { + margin: 0.5rem 0 0.25rem 0; + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* Summary section spacing */ +.summary-section { + margin-top: 0.5rem; +} + +.summary-section-lg { + margin-top: 1rem; +} + +/* Land breakdown note chips */ +.land-note-chip-expand { + background: #0f172a; + border-color: #34d399; + color: #a7f3d0; +} + +.land-note-chip-counts { + background: #111827; + border-color: #60a5fa; + color: #bfdbfe; +} + +/* Land breakdown list */ +.land-breakdown-list { + list-style: none; + padding: 0; + margin: 0.35rem 0 0; + display: grid; + gap: 0.35rem; +} + +.land-breakdown-item { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: flex-start; +} + +.land-breakdown-subs { + list-style: none; + padding: 0; + margin: 0.2rem 0 0; + display: grid; + gap: 0.15rem; + flex: 1 0 100%; +} + +.land-breakdown-sub { + font-size: 0.85rem; + color: #e5e7eb; + opacity: 0.85; +} + +/* Deck metrics wrap */ +.deck-metrics-wrap { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-start; +} + +/* Combo summary styling */ +.combo-summary { + cursor: pointer; + user-select: none; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #12161c; + font-weight: 600; +} + +/* Mana analytics row grid */ +.mana-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + align-items: stretch; +} + +/* Mana panel container */ +.mana-panel { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem; + background: #0f1115; +} + +/* Mana panel heading */ +.mana-panel-heading { + margin-bottom: 0.35rem; + font-weight: 600; +} + +/* Chart bars container */ +.chart-bars { + display: flex; + gap: 14px; + align-items: flex-end; + height: 140px; +} + +/* Chart column center-aligned text */ +.chart-column { + text-align: center; +} + +/* Chart SVG cursor */ +.chart-svg { + cursor: pointer; +} + +/* Existing card tile styles (for reference/consolidation) */ +.card-tile { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.card-tile:hover { + background: var(--hover); +} + +/* Theme detail card styles (for reference/consolidation) */ +.theme-detail-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} diff --git a/code/web/static/styles.css b/code/web/static/styles.css index eda7352..3cbc2f8 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -1,738 +1,2798 @@ +/* Tailwind CSS Entry Point */ + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* ! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com */ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.\!container { + width: 100% !important; +} + +.container { + width: 100%; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.visible { + visibility: visible; +} + +.collapse { + visibility: collapse; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.m-0 { + margin: 0px; +} + +.-my-1\.5 { + margin-top: -0.375rem; + margin-bottom: -0.375rem; +} + +.my-1 { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.my-1\.5 { + margin-top: 0.375rem; + margin-bottom: 0.375rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-3\.5 { + margin-top: 0.875rem; + margin-bottom: 0.875rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-1\.5 { + margin-bottom: 0.375rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-3\.5 { + margin-bottom: 0.875rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-6 { + margin-left: 1.5rem; +} + +.ml-auto { + margin-left: auto; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-0\.5 { + margin-top: 0.125rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-1\.5 { + margin-top: 0.375rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.\!block { + display: block !important; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.\!grid { + display: grid !important; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-12 { + height: 3rem; +} + +.h-auto { + height: auto; +} + +.min-h-\[1\.1em\] { + min-height: 1.1em; +} + +.min-h-\[1rem\] { + min-height: 1rem; +} + +.w-24 { + width: 6rem; +} + +.w-full { + width: 100%; +} + +.min-w-\[160px\] { + min-width: 160px; +} + +.min-w-\[2\.5rem\] { + min-width: 2.5rem; +} + +.min-w-\[220px\] { + min-width: 220px; +} + +.max-w-\[230px\] { + max-width: 230px; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-shrink { + flex-shrink: 1; +} + +.grow { + flex-grow: 1; +} + +.border-collapse { + border-collapse: collapse; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-pointer { + cursor: pointer; +} + +.select-all { + -webkit-user-select: all; + -moz-user-select: all; + user-select: all; +} + +.resize { + resize: both; +} + +.list-none { + list-style-type: none; +} + +.grid-cols-\[2fr_1fr\] { + grid-template-columns: 2fr 1fr; +} + +.grid-cols-\[repeat\(auto-fill\2c minmax\(230px\2c 1fr\)\)\] { + grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-1\.5 { + gap: 0.375rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-2\.5 { + gap: 0.625rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-3\.5 { + gap: 0.875rem; +} + +.gap-4 { + gap: 1rem; +} + +.overflow-hidden { + overflow: hidden; +} + +.text-ellipsis { + text-overflow: ellipsis; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded-\[10px\] { + border-radius: 10px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-0 { + border-width: 0px; +} + +.border-\[var\(--border\)\] { + border-color: var(--border); +} + +.bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); +} + +.p-0 { + padding: 0px; +} + +.p-2 { + padding: 0.5rem; +} + +.px-1\.5 { + padding-left: 0.375rem; + padding-right: 0.375rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-\[11px\] { + font-size: 11px; +} + +.text-\[13px\] { + font-size: 13px; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-medium { + font-weight: 500; +} + +.font-normal { + font-weight: 400; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.capitalize { + text-transform: capitalize; +} + +.italic { + font-style: italic; +} + +.text-\[var\(--text\)\] { + color: var(--text); +} + +.text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity, 1)); +} + +.underline { + text-decoration-line: underline; +} + +.no-underline { + text-decoration-line: none; +} + +.opacity-30 { + opacity: 0.3; +} + +.opacity-70 { + opacity: 0.7; +} + +.opacity-85 { + opacity: 0.85; +} + +.outline { + outline-style: solid; +} + +.ring { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +.\[start\:end\] { + start: end; +} + +/* Import custom CSS (not purged by Tailwind) */ + /* Base */ + :root{ - /* MTG color palette (approx from provided values) */ - --banner-h: 52px; - --sidebar-w: 260px; - --green-main: rgb(0,115,62); - --green-light: rgb(196,211,202); - --blue-main: rgb(14,104,171); - --blue-light: rgb(179,206,234); - --red-main: rgb(211,32,42); - --red-light: rgb(235,159,130); - --white-main: rgb(249,250,244); - --white-light: rgb(248,231,185); - --black-main: rgb(21,11,0); - --black-light: rgb(166,159,157); - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; - --ring: #60a5fa; /* focus ring */ - --ok: #16a34a; /* success */ - --warn: #f59e0b; /* warning */ - --err: #ef4444; /* error */ - /* Surface overrides for specific regions (default to panel) */ - --surface-banner: var(--panel); - --surface-banner-text: var(--text); - --surface-sidebar: var(--panel); - --surface-sidebar-text: var(--text); + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; + /* focus ring */ + --ok: #16a34a; + /* success */ + --warn: #f59e0b; + /* warning */ + --err: #ef4444; + /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); } /* Light blend between Slate and Parchment (leans gray) */ + [data-theme="light-blend"]{ - --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ - --panel: #ffffff; /* crisp panels for readability */ - --text: #0b0d12; - --muted: #6b655d; /* slightly warm muted */ - --border: #d6d1c7; /* neutral warm-gray border */ - /* Slightly darker banner/sidebar for separation */ - --surface-banner: #1a1b1e; - --surface-sidebar: #1a1b1e; - --surface-banner-text: #e8e8e8; - --surface-sidebar-text: #e8e8e8; + --bg: #e8e2d0; + /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; + /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; + /* slightly warm muted */ + --border: #d6d1c7; + /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; } [data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; } + [data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; } + [data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; /* darker green */ - --red-main: #c62828; /* deeper red */ - --blue-main: #1565c0; /* balanced blue */ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; + /* darker green */ + --red-main: #c62828; + /* deeper red */ + --blue-main: #1565c0; + /* balanced blue */ } -*{box-sizing:border-box} -html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} + +*{ + box-sizing:border-box +} + +html{ + height:100%; + overflow-x:hidden; + overflow-y:scroll; + max-width:100vw; +} + body { - font-family: system-ui, Arial, sans-serif; - margin: 0; - color: var(--text); - background: var(--bg); - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: auto; + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: scroll; } + /* Honor HTML hidden attribute across the app */ -[hidden] { display: none !important; } -/* Accessible focus ring for keyboard navigation */ -.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -/* Top banner */ -.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } -.top-banner{ min-height: var(--banner-h); } -.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } -.top-banner .top-inner > div{ min-width:0; } -@media (max-width: 1100px){ - .top-banner .top-inner{ grid-auto-rows:auto; } - .top-banner .top-inner select{ max-width:140px; } + +[hidden] { + display: none !important; +} + +/* Accessible focus ring for keyboard navigation */ + +.focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +/* Top banner - simplified, no changes on sidebar toggle */ + +.top-banner{ + position:sticky; + top:0; + z-index:10; + background: var(--surface-banner); + color: var(--surface-banner-text); + border-bottom:1px solid var(--border); + box-shadow:0 2px 6px rgba(0,0,0,.4); + min-height: var(--banner-h); +} + +.top-banner .top-inner{ + margin:0; + padding:.4rem 15px; + display:flex; + align-items:center; + width:100%; + box-sizing:border-box; +} + +.top-banner h1{ + font-size: 1.1rem; + margin:0; + margin-left: 25px; +} + +.flex-row{ + display: flex; + align-items: center; + gap: 25px; +} + +.top-banner .banner-left{ + width: 260px !important; + flex-shrink: 0 !important; +} + +/* Hide elements on all screen sizes */ + +#btn-open-permalink{ + display:none !important; +} + +#banner-status{ + display:none !important; +} + +.top-banner #theme-reset{ + display:none !important; } -.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } -.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } -.banner-status.busy{ color:#fbbf24; } -.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } -.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } /* Layout */ -.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } -.sidebar{ - background: var(--surface-sidebar); - color: var(--surface-sidebar-text); - border-right: 1px solid var(--border); - padding: 1rem; - position: fixed; - top: var(--banner-h); - left: 0; - bottom: 0; - overflow: auto; - width: var(--sidebar-w); - z-index: 9; /* below the banner (z=10) */ - box-shadow: 2px 0 10px rgba(0,0,0,.18); - display: flex; - flex-direction: column; + +.layout{ + display:grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr); + flex: 1 0 auto; +} + +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; + /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} + +.content{ + padding: 1.25rem 1.5rem; + grid-column: 2; + min-width: 0; } -.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } /* Collapsible sidebar behavior */ -body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } -body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } -body.nav-collapsed .content{ grid-column: 2; } -body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } -body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } + +body.nav-collapsed .layout{ + grid-template-columns: 0 minmax(0, 1fr); +} + +body.nav-collapsed .sidebar{ + transform: translateX(-100%); + visibility: hidden; +} + +body.nav-collapsed .content{ + grid-column: 2; +} + +/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ + /* Smooth hide/show on mobile while keeping fixed positioning */ -.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } + +.sidebar{ + transition: transform .2s ease-out, visibility .2s linear; + overflow-x: hidden; +} + /* Suppress sidebar transitions during page load to prevent pop-in */ -body.no-transition .sidebar{ transition: none !important; } + +body.no-transition .sidebar{ + transition: none !important; +} + /* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ -body.htmx-settling .sidebar{ transition: none !important; } -body.htmx-settling .layout{ transition: none !important; } -body.htmx-settling .content{ transition: none !important; } -body.htmx-settling *{ transition-duration: 0s !important; } + +body.htmx-settling .sidebar{ + transition: none !important; +} + +body.htmx-settling .layout{ + transition: none !important; +} + +body.htmx-settling .content{ + transition: none !important; +} + +body.htmx-settling *{ + transition-duration: 0s !important; +} /* Mobile tweaks */ + @media (max-width: 900px){ - :root{ --sidebar-w: 240px; } - .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } - .banner-status{ padding-left: .5rem; } - .layout{ grid-template-columns: 0 1fr; } - .sidebar{ transform: translateX(-100%); visibility: hidden; } - body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } - body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } - .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } - .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } - /* Spacing tweaks: tighter left, larger gaps between visible items */ - .top-banner .top-inner > div{ gap: 25px !important; } - .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } - /* Mobile: show only Menu, Title, and Theme selector */ - #btn-open-permalink{ display:none !important; } - #banner-status{ display:none !important; } - #health-dot{ display:none !important; } - .top-banner #theme-reset{ display:none !important; } + :root{ + --sidebar-w: 240px; + } + + .layout{ + grid-template-columns: 0 1fr; + } + + .sidebar{ + transform: translateX(-100%); + visibility: hidden; + } + + body:not(.nav-collapsed) .layout{ + grid-template-columns: var(--sidebar-w) 1fr; + } + + body:not(.nav-collapsed) .sidebar{ + transform: translateX(0); + visibility: visible; + } + + .content{ + padding: .9rem .6rem; + max-width: 100vw; + box-sizing: border-box; + overflow-x: hidden; + } } /* Additional mobile spacing for bottom floating controls */ + @media (max-width: 720px) { - .content { - padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ - } + .content { + padding-bottom: 6rem !important; + /* Extra bottom padding to account for floating controls */ + } } -.brand h1{ display:none; } -.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } -.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } -.dot.green{ background: var(--green-main); } -.dot.blue{ background: var(--blue-main); } -.dot.red{ background: var(--red-main); } -.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } -.dot.black{ background: var(--black-light); } +.brand h1{ + display:none; +} -.nav{ display:flex; flex-direction:column; gap:.35rem; } -.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } -.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } +.brand{ + padding-top: 0; + margin-top: 0; +} + +.mana-dots{ + display:flex; + gap:.35rem; + margin-bottom:.5rem; + margin-top: 0; + padding-top: 0; +} + +.mana-dots .dot{ + width:12px; + height:12px; + border-radius:50%; + display:inline-block; + border:1px solid rgba(0,0,0,.35); + box-shadow:0 1px 2px rgba(0,0,0,.3) inset; +} + +.dot.green{ + background: var(--green-main); +} + +.dot.blue{ + background: var(--blue-main); +} + +.dot.red{ + background: var(--red-main); +} + +.dot.white{ + background: var(--white-light); + border-color: rgba(0,0,0,.2); +} + +.dot.black{ + background: var(--black-light); +} + +.nav{ + display:flex; + flex-direction:column; + gap:.35rem; +} + +.nav a{ + color: var(--surface-sidebar-text); + text-decoration:none; + padding:.4rem .5rem; + border-radius:6px; + border:1px solid transparent; +} + +.nav a:hover{ + background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); + border-color: var(--border); +} /* Sidebar theme controls anchored at bottom */ -.sidebar .nav { flex: 1 1 auto; } -.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } -.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } -.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } -.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } -.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } + +.sidebar .nav { + flex: 1 1 auto; +} + +.sidebar-theme { + margin-top: auto; + padding-top: .75rem; + border-top: 1px solid var(--border); +} + +.sidebar-theme-label { + display:block; + color: var(--surface-sidebar-text); + font-size: 12px; + opacity:.8; + margin: 0 0 .35rem .1rem; +} + +.sidebar-theme-row { + display:flex; + align-items:center; + gap:.5rem; + flex-wrap: nowrap; +} + +.sidebar-theme-row select { + background: var(--panel); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.3rem .4rem; + flex: 1 1 auto; + min-width: 0; +} + +.sidebar-theme-row .btn-ghost { + background: transparent; + color: var(--surface-sidebar-text); + border:1px solid var(--border); + flex-shrink: 0; + white-space: nowrap; +} /* Simple two-column layout for inspect panel */ -.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } -.two-col .grow { min-width: 0; } -.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } -@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +.two-col { + display: grid; + grid-template-columns: 1fr 320px; + gap: 1rem; + align-items: start; +} + +.two-col .grow { + min-width: 0; +} + +.card-preview img { + width: 100%; + height: auto; + border-radius: 10px; + box-shadow: 0 6px 18px rgba(0,0,0,.35); + border:1px solid var(--border); + background: var(--panel); +} + +@media (max-width: 900px) { + .two-col { + grid-template-columns: 1fr; + } +} /* Left-rail variant puts the image first */ -.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } -/* Ensure left-rail variant also collapses to 1 column on small screens */ -@media (max-width: 900px){ - .two-col.two-col-left-rail{ grid-template-columns: 1fr; } - /* So the commander image doesn't dominate on mobile */ - .two-col .card-preview{ max-width: 360px; margin: 0 auto; } - .two-col .card-preview img{ width: 100%; height: auto; } + +.two-col.two-col-left-rail{ + grid-template-columns: 320px 1fr; +} + +/* Ensure left-rail variant also collapses to 1 column on small screens */ + +@media (max-width: 900px){ + .two-col.two-col-left-rail{ + grid-template-columns: 1fr; + } + + /* So the commander image doesn't dominate on mobile */ + + .two-col .card-preview{ + max-width: 360px; + margin: 0 auto; + } + + .two-col .card-preview img{ + width: 100%; + height: auto; + } +} + +.card-preview.card-sm{ + max-width:200px; } -.card-preview.card-sm{ max-width:200px; } /* Buttons, inputs */ -button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } -button:hover{ filter:brightness(1.05); } + +button{ + background: var(--blue-main); + color:#fff; + border:none; + border-radius:6px; + padding:.45rem .7rem; + cursor:pointer; +} + +button:hover{ + filter:brightness(1.05); +} + /* Anchor-style buttons */ -.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } -.btn:hover{ filter:brightness(1.05); text-decoration:none; } -.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } -label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } -.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } -.color-identity .mana + .mana{ margin-left:4px; } -.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } -.mana-W{ background:#f9fafb; border-color:#d1d5db; } -.mana-U{ background:#3b82f6; border-color:#1d4ed8; } -.mana-B{ background:#111827; border-color:#1f2937; } -.mana-R{ background:#ef4444; border-color:#b91c1c; } -.mana-G{ background:#10b981; border-color:#047857; } -.mana-C{ background:#d3d3d3; border-color:#9ca3af; } -select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } -fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } -small, .muted{ color: var(--muted); } -.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } -.partner-preview[hidden]{ display:none !important; } -.partner-preview__header{ font-weight:600; } -.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } -.partner-preview__art{ flex:0 0 auto; } -.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } -.partner-preview__details{ flex:1 1 180px; min-width:0; } -.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } -.partner-preview__pairing{ margin-top:.35rem; } -.partner-preview__themes{ margin-top:.35rem; font-size:12px; } -.partner-preview--static{ margin-bottom:.5rem; } -.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +.btn{ + display:inline-block; + background: var(--blue-main); + color:#fff; + border:none; + border-radius:6px; + padding:.45rem .7rem; + cursor:pointer; + text-decoration:none; + line-height:1; +} + +.btn:hover{ + filter:brightness(1.05); + text-decoration:none; +} + +.btn.disabled, .btn[aria-disabled="true"]{ + opacity:.6; + cursor:default; + pointer-events:none; +} + +label{ + display:inline-flex; + flex-direction:column; + gap:.25rem; + margin-right:.75rem; +} + +.color-identity{ + display:inline-flex; + align-items:center; + gap:.35rem; +} + +.color-identity .mana + .mana{ + margin-left:4px; +} + +.mana{ + display:inline-block; + width:16px; + height:16px; + border-radius:50%; + border:1px solid var(--border); + box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; +} + +.mana-W{ + background:#f9fafb; + border-color:#d1d5db; +} + +.mana-U{ + background:#3b82f6; + border-color:#1d4ed8; +} + +.mana-B{ + background:#111827; + border-color:#1f2937; +} + +.mana-R{ + background:#ef4444; + border-color:#b91c1c; +} + +.mana-G{ + background:#10b981; + border-color:#047857; +} + +.mana-C{ + background:#d3d3d3; + border-color:#9ca3af; +} + +select,input[type="text"],input[type="number"]{ + background: var(--panel); + color:var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.35rem .4rem; +} + +fieldset{ + border:1px solid var(--border); + border-radius:8px; + padding:.75rem; + margin:.75rem 0; +} + +small, .muted{ + color: var(--muted); +} + +.partner-preview{ + border:1px solid var(--border); + border-radius:8px; + background: var(--panel); + padding:.75rem; + margin-bottom:.5rem; +} + +.partner-preview[hidden]{ + display:none !important; +} + +.partner-preview__header{ + font-weight:600; +} + +.partner-preview__layout{ + display:flex; + gap:.75rem; + align-items:flex-start; + flex-wrap:wrap; +} + +.partner-preview__art{ + flex:0 0 auto; +} + +.partner-preview__art img{ + width:140px; + max-width:100%; + border-radius:6px; + box-shadow:0 4px 12px rgba(0,0,0,.35); +} + +.partner-preview__details{ + flex:1 1 180px; + min-width:0; +} + +.partner-preview__role{ + margin-top:.2rem; + font-size:12px; + color:var(--muted); + letter-spacing:.04em; + text-transform:uppercase; +} + +.partner-preview__pairing{ + margin-top:.35rem; +} + +.partner-preview__themes{ + margin-top:.35rem; + font-size:12px; +} + +.partner-preview--static{ + margin-bottom:.5rem; +} + +.partner-card-preview img{ + box-shadow:0 4px 12px rgba(0,0,0,.3); +} /* Toasts */ -.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } -.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } -.toast.hide{ opacity:0; transform: translateY(6px); } -.toast.success{ border-color: rgba(22,163,74,.4); } -.toast.error{ border-color: rgba(239,68,68,.45); } -.toast.warn{ border-color: rgba(245,158,11,.45); } + +.toast-host{ + position: fixed; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 9999; +} + +.toast{ + background: rgba(17,24,39,.95); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:10px; + padding:.5rem .65rem; + box-shadow: 0 8px 24px rgba(0,0,0,.35); + transition: transform .2s ease, opacity .2s ease; +} + +.toast.hide{ + opacity:0; + transform: translateY(6px); +} + +.toast.success{ + border-color: rgba(22,163,74,.4); +} + +.toast.error{ + border-color: rgba(239,68,68,.45); +} + +.toast.warn{ + border-color: rgba(245,158,11,.45); +} /* Skeletons */ -[data-skeleton]{ position: relative; } -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } -[data-skeleton-placeholder]{ display:none; pointer-events:none; } -[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } + +[data-skeleton]{ + position: relative; +} + +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ + opacity: 0; +} + +[data-skeleton-placeholder]{ + display:none; + pointer-events:none; +} + +[data-skeleton].is-loading > [data-skeleton-placeholder]{ + display:flex; + flex-direction:column; + opacity:1; +} + [data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton][data-skeleton-overlay="false"]::before{ + display:none !important; +} + [data-skeleton]::after{ - content: ''; - position: absolute; inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; + content: ''; + position: absolute; + inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; } -[data-skeleton].is-loading::after{ display:block; } + +[data-skeleton].is-loading::after{ + display:block; +} + [data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} + +[data-skeleton][data-skeleton-label=""]::before{ + content:''; +} + +@keyframes shimmer{ + 0%{ + background-position: 200% 0; + } + + 100%{ + background-position: -200% 0; + } } -[data-skeleton][data-skeleton-label=""]::before{ content:''; } -@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } /* Banner */ -.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } -.banner h1{ font-size: 2rem; margin:0 0 .35rem; } -.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +.banner{ + background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2rem 1.6rem; + margin-bottom: 1rem; + box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; +} + +.banner h1{ + font-size: 2rem; + margin:0 0 .35rem; +} + +.banner .subtitle{ + color: var(--muted); + font-size:.95rem; +} /* Home actions */ -.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } -.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } -.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } -.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +.actions-grid{ + display:grid; + grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); + gap: .75rem; +} + +.action-button{ + display:block; + text-decoration:none; + color: var(--text); + border:1px solid var(--border); + background: var(--panel); + padding:1.25rem; + border-radius:10px; + text-align:center; + font-weight:600; +} + +.action-button:hover{ + border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.action-button.primary{ + background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); + border-color: #274766; +} + +/* Home page darker buttons */ + +.home-button.btn-secondary { + background: #1a1d24; + border-color: #2a2d35; +} + +.home-button.btn-secondary:hover { + background: #22252d; + border-color: #3a3d45; +} + +.home-button.btn-primary { + background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15)); + border-color: #2a5580; +} + +.home-button.btn-primary:hover { + background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25)); + border-color: #3a6590; +} /* Card grid for added cards (responsive, compact tiles) */ + .card-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ - gap: .5rem; - margin-top:.5rem; - justify-content: start; /* pack as many as possible per row */ - /* Prevent scroll chaining bounce that can cause flicker near bottom */ - overscroll-behavior: contain; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 640px 420px; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); + /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; + /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; } + @media (max-width: 420px){ - .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .card-tile{ width: 100%; } - .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } + .card-grid{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .card-tile{ + width: 100%; + } + + .card-tile img{ + width: 100%; + max-width: 160px; + margin: 0 auto; + } } + .card-tile{ - width:170px; - position: relative; - background: var(--panel); - border:1px solid var(--border); - border-radius:6px; - padding:.25rem .25rem .4rem; - text-align:center; + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; } -.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } + +.card-tile.game-changer{ + border-color: var(--red-main); + box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; +} + .card-tile.locked{ - /* Subtle yellow/goldish-white accent for locked cards */ - border-color: #f5e6a8; /* soft parchment gold */ - box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; + /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; } + .card-tile.must-include{ - border-color: rgba(74,222,128,.85); - box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); } + .card-tile.must-exclude{ - border-color: rgba(239,68,68,.85); - box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; - opacity: .95; + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; } + .card-tile.must-include.must-exclude{ - border-color: rgba(249,115,22,.85); - box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} + +.card-tile img{ + width:160px; + height:auto; + border-radius:6px; + box-shadow: 0 6px 18px rgba(0,0,0,.35); + background:#111; +} + +.card-tile .name{ + font-weight:600; + margin-top:.25rem; + font-size:.92rem; +} + +.card-tile .reason{ + color:var(--muted); + font-size:.85rem; + margin-top:.15rem; } -.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } -.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } -.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } .must-have-controls{ - display:flex; - justify-content:center; - gap:.35rem; - flex-wrap:wrap; - margin-top:.35rem; + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; } + .must-have-btn{ - border:1px solid var(--border); - background:rgba(30,41,59,.6); - color:#f8fafc; - font-size:11px; - text-transform:uppercase; - letter-spacing:.06em; - padding:.25rem .6rem; - border-radius:9999px; - cursor:pointer; - transition: all .18s ease; + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; } + .must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ - border-color: rgba(74,222,128,.75); - background: rgba(74,222,128,.18); - color: #bbf7d0; - box-shadow: 0 0 0 1px rgba(16,185,129,.25); + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); } + .must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ - border-color: rgba(239,68,68,.75); - background: rgba(239,68,68,.18); - color: #fecaca; - box-shadow: 0 0 0 1px rgba(239,68,68,.25); + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); } + .must-have-btn:focus-visible{ - outline:2px solid rgba(59,130,246,.6); - outline-offset:2px; + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; } + .card-tile.must-exclude .must-have-btn.include[data-active="0"], .card-tile.must-include .must-have-btn.exclude[data-active="0"]{ - opacity:.65; + opacity:.65; } -.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } -.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } +.group-grid{ + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 540px 360px; +} + +.alt-list{ + list-style:none; + padding:0; + margin:0; + display:grid; + gap:.25rem; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 320px 220px; +} + +.alt-option{ + display:block !important; + width:100%; + max-width:100%; + text-align:left; + white-space:normal !important; + word-wrap:break-word !important; + overflow-wrap:break-word !important; + line-height:1.3 !important; + padding:0.5rem 0.7rem !important; +} /* Shared ownership badge for card tiles and stacked images */ + .owned-badge{ - position:absolute; - top:6px; - left:6px; - background:rgba(17,24,39,.9); - color:#e5e7eb; - border:1px solid var(--border); - border-radius:12px; - font-size:12px; - line-height:18px; - height:18px; - min-width:18px; - padding:0 6px; - text-align:center; - pointer-events:none; - z-index:2; + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; } /* Step 1 candidate grid (200px-wide scaled images) */ + .candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; } + .candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} + +.candidate-tile .img-btn{ + display:block; + width:100%; + padding:0; + background:transparent; + border:none; + cursor:pointer; +} + +.candidate-tile img{ + width:100%; + max-width:200px; + height:auto; + border-radius:8px; + box-shadow:0 6px 18px rgba(0,0,0,.35); + background: var(--panel); + display:block; + margin:0 auto; +} + +.candidate-tile .meta{ + text-align:center; + margin-top:.35rem; +} + +.candidate-tile .name{ + font-weight:600; + font-size:.95rem; +} + +.candidate-tile .score{ + color:var(--muted); + font-size:.85rem; } -.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } -.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } -.candidate-tile .meta{ text-align:center; margin-top:.35rem; } -.candidate-tile .name{ font-weight:600; font-size:.95rem; } -.candidate-tile .score{ color:var(--muted); font-size:.85rem; } /* Deck summary: highlight game changers */ -.game-changer { color: var(--green-main); } -.stack-card.game-changer { outline: 2px solid var(--green-main); } + +.game-changer { + color: var(--green-main); +} + +.stack-card.game-changer { + outline: 2px solid var(--green-main); +} /* Image button inside card tiles */ -.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +.card-tile .img-btn{ + display:block; + padding:0; + background:transparent; + border:none; + cursor:pointer; + width:100%; +} /* Stage Navigator */ -.stage-nav { margin:.5rem 0 1rem; } -.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } -.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } -.stage-nav .stage-item.done .stage-link { opacity:.75; } -.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } -.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } -.stage-nav .name { font-size:12px; } + +.stage-nav { + margin:.5rem 0 1rem; +} + +.stage-nav ol { + list-style:none; + padding:0; + margin:0; + display:flex; + gap:.35rem; + flex-wrap:wrap; +} + +.stage-nav .stage-link { + display:flex; + align-items:center; + gap:.4rem; + background: var(--panel); + border:1px solid var(--border); + color:var(--text); + border-radius:999px; + padding:.25rem .6rem; + cursor:pointer; +} + +.stage-nav .stage-item.done .stage-link { + opacity:.75; +} + +.stage-nav .stage-item.current .stage-link { + box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; + border-color:#3b82f6; +} + +.stage-nav .idx { + display:inline-grid; + place-items:center; + width:20px; + height:20px; + border-radius:50%; + background:#1f2937; + font-size:12px; +} + +.stage-nav .name { + font-size:12px; +} /* Build controls sticky box tweaks */ -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 10px; - margin: 0.5rem 0; - box-shadow: 0 4px 12px rgba(0,0,0,.25); + +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); } @media (max-width: 1024px){ - :root { --banner-offset: 56px; } - .build-controls { - position: fixed !important; /* Fixed to viewport instead of sticky */ - bottom: 0 !important; /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; /* Override top positioning */ - border-radius: 0 !important; /* Remove border radius for full width */ - margin: 0 !important; /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; /* Higher z-index to ensure it's above content */ - } + :root { + --banner-offset: 56px; + } + + .build-controls { + position: fixed !important; + /* Fixed to viewport instead of sticky */ + bottom: 0 !important; + /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; + /* Override top positioning */ + border-radius: 0 !important; + /* Remove border radius for full width */ + margin: 0 !important; + /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; + /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; + /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; + /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; + /* Higher z-index to ensure it's above content */ + } } + @media (min-width: 721px){ - :root { --banner-offset: 48px; } + :root { + --banner-offset: 48px; + } } /* Progress bar */ -.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } -.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } -.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +.progress { + position: relative; + height: 10px; + background: var(--panel); + border:1px solid var(--border); + border-radius: 999px; + overflow: hidden; +} + +.progress .bar { + position:absolute; + left:0; + top:0; + bottom:0; + width: 0%; + background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); +} + +.progress.flash { + box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; +} /* Chips */ -.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } -.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } + +.chip { + display:inline-flex; + align-items:center; + gap:.35rem; + background: var(--panel); + border:1px solid var(--border); + color:var(--text); + border-radius:999px; + padding:.2rem .55rem; + font-size:12px; +} + +.chip .dot { + width:8px; + height:8px; + border-radius:50%; + background:#6b7280; +} + +.chip:hover { + background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); + border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); +} + +.chip.active { + background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); + border-color: #3b82f6; + color: #60a5fa; + font-weight: 600; + box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; +} + +.chip.active:hover { + background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); + border-color: #60a5fa; +} /* Cards toolbar */ -.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } -.cards-toolbar input[type="text"]{ min-width: 220px; } -.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } -.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +.cards-toolbar{ + display:flex; + flex-wrap:wrap; + gap:.5rem .75rem; + align-items:center; + margin:.5rem 0 .25rem; +} + +.cards-toolbar input[type="text"]{ + min-width: 220px; +} + +.cards-toolbar .sep{ + width:1px; + height:20px; + background: var(--border); + margin:0 .25rem; +} + +.cards-toolbar .hint{ + color: var(--muted); + font-size:12px; +} /* Collapse groups and reason toggle */ -.group{ margin:.5rem 0; } -.group-header{ display:flex; align-items:center; gap:.5rem; } -.group-header h5{ margin:.4rem 0; } -.group-header .count{ color: var(--muted); font-size:12px; } -.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } -.group-grid[data-collapsed]{ display:none; } -.hide-reasons .card-tile .reason{ display:none; } -.card-tile.force-show .reason{ display:block !important; } -.card-tile.force-hide .reason{ display:none !important; } -.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } -.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } -.chips-inline .chip{ cursor:pointer; user-select:none; } + +.group{ + margin:.5rem 0; +} + +.group-header{ + display:flex; + align-items:center; + gap:.5rem; +} + +.group-header h5{ + margin:.4rem 0; +} + +.group-header .count{ + color: var(--muted); + font-size:12px; +} + +.group-header .toggle{ + margin-left:auto; + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.2rem .5rem; + font-size:12px; + cursor:pointer; +} + +.group-grid[data-collapsed]{ + display:none; +} + +.hide-reasons .card-tile .reason{ + display:none; +} + +.card-tile.force-show .reason{ + display:block !important; +} + +.card-tile.force-hide .reason{ + display:none !important; +} + +.btn-why{ + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); + color: var(--text); + border:1px solid var(--border); + border-radius:6px; + padding:.15rem .4rem; + font-size:12px; + cursor:pointer; +} + +.chips-inline{ + display:flex; + gap:.35rem; + flex-wrap:wrap; + align-items:center; +} + +.chips-inline .chip{ + cursor:pointer; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} /* Inline error banner */ -.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } -.inline-error-banner .muted{ color:#fda4af; } + +.inline-error-banner{ + background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); + border:1px solid #b91c1c; + color:#b91c1c; + padding:.5rem .6rem; + border-radius:8px; + margin-bottom:.5rem; +} + +.inline-error-banner .muted{ + color:#fda4af; +} /* Alternatives panel */ -.alts ul{ list-style:none; padding:0; margin:0; } -.alts li{ display:flex; align-items:center; gap:.4rem; } + +.alts ul{ + list-style:none; + padding:0; + margin:0; +} + +.alts li{ + display:flex; + align-items:center; + gap:.4rem; +} + /* LQIP blur/fade-in for thumbnails */ -img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } -img.lqip.loaded { filter: blur(0); opacity: 1; } + +img.lqip { + filter: blur(8px); + opacity: .6; + transition: filter .25s ease-out, opacity .25s ease-out; +} + +img.lqip.loaded { + filter: blur(0); + opacity: 1; +} /* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ + @media (prefers-reduced-motion: reduce) { - * { scroll-behavior: auto !important; } - img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } + * { + scroll-behavior: auto !important; + } + + img.lqip { + transition: none !important; + filter: none !important; + opacity: 1 !important; + } } /* Virtualization wrapper should mirror grid to keep multi-column flow */ -.virt-wrapper { display: grid; } + +.virt-wrapper { + display: grid; +} /* Mobile responsive fixes for horizontal scrolling issues */ + @media (max-width: 768px) { - /* Prevent horizontal overflow */ - html, body { - overflow-x: hidden !important; - width: 100% !important; - max-width: 100vw !important; - } + /* Prevent horizontal overflow */ - /* Test hand responsive adjustments */ - #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } - /* Modal & form layout fixes (original block retained inside media query) */ - /* Fix modal layout on mobile */ - .modal { - padding: 10px !important; - box-sizing: border-box; - } - .modal-content { - width: 100% !important; - max-width: calc(100vw - 20px) !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - /* Force single column for include/exclude grid */ - .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } - /* Fix basics grid */ - .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } - /* Ensure all inputs and textareas fit properly */ - .modal input, + /* Test hand responsive adjustments */ + + #test-hand{ + --card-w: 170px !important; + --card-h: 238px !important; + --overlap: .5 !important; + } + + /* Modal & form layout fixes (original block retained inside media query) */ + + /* Fix modal layout on mobile */ + + .modal { + padding: 10px !important; + box-sizing: border-box; + } + + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + + /* Force single column for include/exclude grid */ + + .include-exclude-grid { + display: flex !important; + flex-direction: column !important; + gap: 1rem !important; + } + + /* Fix basics grid */ + + .basics-grid { + grid-template-columns: 1fr !important; + gap: 1rem !important; + } + + /* Ensure all inputs and textareas fit properly */ + + .modal input, .modal textarea, - .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } - /* Fix chips containers */ - .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } - /* Ensure fieldsets don't overflow */ - .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } - /* Fix any inline styles that might cause overflow */ - .modal fieldset > div, - .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } + .modal select { + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; + min-width: 0 !important; + } + + /* Fix chips containers */ + + .modal [id$="_chips_container"] { + max-width: 100% !important; + overflow-x: hidden !important; + word-wrap: break-word !important; + } + + /* Ensure fieldsets don't overflow */ + + .modal fieldset { + max-width: 100% !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + + /* Fix any inline styles that might cause overflow */ + + .modal fieldset > div, + .modal fieldset > div > div { + max-width: 100% !important; + overflow-x: hidden !important; + } } @media (max-width: 640px){ - #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } - /* Generic stack shrink */ - .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } + #test-hand{ + --card-w: 150px !important; + --card-h: 210px !important; + } + + /* Generic stack shrink */ + + .stack-wrap:not(#test-hand){ + --card-w: 150px; + --card-h: 210px; + } } @media (max-width: 560px){ - #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } - #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } - #test-hand .stack-card{ flex:0 0 auto; } - .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } + #test-hand{ + --card-w: 140px !important; + --card-h: 196px !important; + padding-bottom:.75rem; + } + + #test-hand .stack-grid{ + display:flex !important; + gap:.5rem; + grid-template-columns:none !important; + overflow-x:auto; + padding-bottom:.25rem; + } + + #test-hand .stack-card{ + flex:0 0 auto; + } + + .stack-wrap:not(#test-hand){ + --card-w: 140px; + --card-h: 196px; + } } @media (max-width: 480px) { - .modal-content { - padding: 12px !important; - margin: 5px !important; - } - - .modal fieldset { - padding: 8px !important; - margin: 6px 0 !important; - } - - /* Enhanced mobile build controls */ - .build-controls { - flex-direction: column !important; - gap: 0.25rem !important; /* Reduced gap */ - align-items: stretch !important; - padding: 0.5rem !important; /* Reduced padding */ - } - - /* Two-column grid layout for mobile build controls */ - .build-controls { - display: grid !important; - grid-template-columns: 1fr 1fr !important; /* Two equal columns */ - grid-gap: 0.25rem !important; - align-items: stretch !important; - } - - .build-controls form { - display: contents !important; /* Allow form contents to participate in grid */ - width: auto !important; - } - - .build-controls button { - flex: none !important; - padding: 0.4rem 0.5rem !important; /* Much smaller padding */ - font-size: 12px !important; /* Smaller font */ - min-height: 36px !important; /* Smaller minimum height */ - line-height: 1.2 !important; - width: 100% !important; /* Full width within grid cell */ - box-sizing: border-box !important; - white-space: nowrap !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - /* Hide non-essential elements on mobile to keep it clean */ - .build-controls .sep, + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; + /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; + /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; + /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; + /* Much smaller padding */ + font-size: 12px !important; + /* Smaller font */ + min-height: 36px !important; + /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; + /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + + .build-controls .sep, .build-controls .replace-toggle, .build-controls label[style*="margin-left"] { - display: none !important; - } - - .build-controls .sep { - display: none !important; /* Hide separators on mobile */ - } + display: none !important; + } + + .build-controls .sep { + display: none !important; + /* Hide separators on mobile */ + } } /* Desktop sizing for Test Hand */ + @media (min-width: 900px) { - #test-hand { --card-w: 280px !important; --card-h: 392px !important; } + #test-hand { + --card-w: 280px !important; + --card-h: 392px !important; + } } /* Analytics accordion styling */ + .analytics-accordion { - transition: all 0.2s ease; + transition: all 0.2s ease; } .analytics-accordion summary { - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.15s ease, border-color 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; } .analytics-accordion summary:hover { - background: #1f2937; - border-color: #374151; + background: #1f2937; + border-color: #374151; } .analytics-accordion summary:active { - transform: scale(0.99); + transform: scale(0.99); } .analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; } .analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; + animation: accordion-slide-down 0.3s ease-out; } @keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; + animation: shimmer 1.5s infinite; } @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } } /* Ideals Slider Styling */ + .ideals-slider { - -webkit-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; } .ideals-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: var(--ring); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + -webkit-transition: all 0.15s ease; + transition: all 0.15s ease; } .ideals-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } .ideals-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: var(--ring); - border: none; - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + -moz-transition: all 0.15s ease; + transition: all 0.15s ease; } .ideals-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } .slider-value { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 4px; + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; } /* ======================================== @@ -740,469 +2800,2840 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } ======================================== */ /* Card browser container */ + .card-browser-container { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } /* Filter panel */ + .card-browser-filters { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; } .filter-section { - display: flex; - flex-direction: column; - gap: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; } .filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; } .filter-row label { - font-weight: 600; - min-width: 80px; - color: var(--text); - font-size: 0.95rem; + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; } .filter-row select, .filter-row input[type="text"], .filter-row input[type="search"] { - flex: 1; - min-width: 150px; - max-width: 300px; + flex: 1; + min-width: 150px; + max-width: 300px; } /* Search bar styling */ + .card-search-wrapper { - position: relative; - flex: 1; - max-width: 100%; + position: relative; + flex: 1; + max-width: 100%; } .card-search-wrapper input[type="search"] { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 1rem; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; } /* Results count and info bar */ + .card-browser-info { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; } .results-count { - font-size: 0.95rem; - color: var(--muted); + font-size: 0.95rem; + color: var(--muted); } .page-indicator { - font-size: 0.95rem; - color: var(--text); - font-weight: 600; + font-size: 0.95rem; + color: var(--text); + font-weight: 600; } /* Card browser grid */ + .card-browser-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); - gap: 0.5rem; - padding: 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - min-height: 480px; - justify-content: start; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; } /* Individual card tile in browser */ + .card-browser-tile { - break-inside: avoid; - display: flex; - flex-direction: column; - background: var(--card-bg, #1a1d24); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; + -moz-column-break-inside: avoid; + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; } .card-browser-tile:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); } .card-browser-tile-image { - position: relative; - width: 100%; - aspect-ratio: 488/680; - overflow: hidden; - background: #0a0b0e; + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; } .card-browser-tile-image img { - width: 100%; - height: 100%; - object-fit: contain; - transition: transform 0.3s ease; + width: 100%; + height: 100%; + -o-object-fit: contain; + object-fit: contain; + transition: transform 0.3s ease; } .card-browser-tile:hover .card-browser-tile-image img { - transform: scale(1.05); + transform: scale(1.05); } .card-browser-tile-info { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; } .card-browser-tile-name { - font-weight: 600; - font-size: 0.95rem; - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; } .card-browser-tile-type { - font-size: 0.85rem; - color: var(--muted); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; } .card-browser-tile-stats { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; } .card-browser-tile-tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; } .card-browser-tile-tags .tag { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - background: rgba(148, 163, 184, 0.15); - color: var(--muted); - border-radius: 3px; - white-space: nowrap; + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; } /* Card Details button on tiles */ + .card-details-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.5rem 0.75rem; - background: var(--primary); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: all 0.2s; - margin-top: 0.5rem; - border: none; - cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; } .card-details-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); } .card-details-btn svg { - flex-shrink: 0; + flex-shrink: 0; } /* Card Preview Modal */ + .preview-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 9999; - align-items: center; - justify-content: center; + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; } .preview-modal.active { - display: flex; + display: flex; } .preview-content { - position: relative; - max-width: 90%; - max-height: 90%; + position: relative; + max-width: 90%; + max-height: 90%; } .preview-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .preview-close { - position: absolute; - top: -40px; - right: 0; - background: rgba(255, 255, 255, 0.9); - color: #000; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 24px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; } .preview-close:hover { - background: #fff; - transform: scale(1.1); + background: #fff; + transform: scale(1.1); } /* Pagination controls */ + .card-browser-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 1rem; - padding: 1rem 0; - flex-wrap: wrap; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; } .card-browser-pagination .btn { - min-width: 120px; + min-width: 120px; } .card-browser-pagination .page-info { - font-size: 0.95rem; - color: var(--text); - padding: 0 1rem; + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; } /* No results message */ + .no-results { - text-align: center; - padding: 3rem 1rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; } .no-results-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin-bottom: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; } .no-results-message { - color: var(--muted); - margin-bottom: 1rem; - line-height: 1.5; + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; } .no-results-filters { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin-bottom: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; } .no-results-filter-tag { - padding: 0.25rem 0.75rem; - background: rgba(148, 163, 184, 0.15); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - color: var(--text); + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); } /* Loading indicator */ + .card-browser-loading { - text-align: center; - padding: 2rem; - color: var(--muted); + text-align: center; + padding: 2rem; + color: var(--muted); } /* Responsive adjustments */ + /* Large tablets and below - reduce to ~180px cards */ + @media (max-width: 1024px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); - } + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } } /* Tablets - reduce to ~160px cards */ + @media (max-width: 768px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); - gap: 0.5rem; - padding: 0.5rem; - } - - .filter-row { - flex-direction: column; - align-items: stretch; - } - - .filter-row label { - min-width: auto; - } - - .filter-row select, + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, .filter-row input { - max-width: 100%; - } - - .card-browser-info { - flex-direction: column; - align-items: flex-start; - } + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } } /* Small tablets/large phones - reduce to ~140px cards */ + @media (max-width: 600px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); - gap: 0.5rem; - } + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } } /* Phones - 2 column layout with flexible width */ + @media (max-width: 480px) { - .card-browser-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; - } - - .card-browser-tile-name { - font-size: 0.85rem; - } - - .card-browser-tile-type { - font-size: 0.75rem; - } - - .card-browser-tile-info { - padding: 0.5rem; - } + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } } /* Theme chips for multi-select */ + .theme-chip { - display: inline-flex; - align-items: center; - background: var(--primary-bg); - color: var(--primary-fg); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-size: 0.9rem; - border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); } .theme-chip button { - margin-left: 0.5rem; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - font-weight: bold; - font-size: 1.2rem; - line-height: 1; + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; } .theme-chip button:hover { - color: var(--error-color); + color: var(--error-color); } /* Card Detail Page Styles */ + .card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; - margin-bottom: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; } .card-tag { - background: var(--ring); - color: white; - padding: 0.35rem 0.75rem; - border-radius: 16px; - font-size: 0.85rem; - font-weight: 500; + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; } .back-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: var(--panel); - color: var(--text); - text-decoration: none; - border-radius: 8px; - border: 1px solid var(--border); - font-weight: 500; - transition: all 0.2s; - margin-bottom: 2rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; } .back-button:hover { - background: var(--ring); - color: white; - border-color: var(--ring); + background: var(--ring); + color: white; + border-color: var(--ring); } /* Card Detail Page - Main Card Image */ + .card-image-large { - flex: 0 0 auto; - max-width: 360px !important; - width: 100%; + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; } .card-image-large img { - width: 100%; - height: auto; - border-radius: 12px; + width: 100%; + height: auto; + border-radius: 12px; } + +/* ============================================ + M2 Component Library Styles + ============================================ */ + +/* === BUTTONS === */ + +/* Button Base - enhanced from existing .btn */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--blue-main); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + text-decoration: none; + line-height: 1.5; + font-weight: 500; + transition: filter 0.15s ease, transform 0.05s ease; + white-space: nowrap; +} + +.btn:hover { + filter: brightness(1.1); + text-decoration: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn.disabled, +.btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Button Variants */ + +.btn-primary { + background: var(--blue-main); + color: #fff; +} + +.btn-secondary { + background: var(--muted); + color: var(--text); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + background: var(--panel); + border-color: var(--text); +} + +.btn-danger { + background: var(--err); + color: #fff; +} + +/* Button Sizes */ + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + +.btn-md { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Icon Button */ + +.btn-icon { + padding: 0.5rem; + aspect-ratio: 1; + justify-content: center; +} + +.btn-icon.btn-sm { + padding: 0.25rem; + font-size: 1rem; +} + +/* Close Button */ + +.btn-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.5rem; + line-height: 1; + z-index: 10; +} + +/* Tag/Chip Button */ + +.btn-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.btn-tag:hover { + background: var(--border); + border-color: var(--text); +} + +.btn-tag-selected { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.btn-tag-remove { + background: transparent; + border: none; + color: inherit; + padding: 0; + margin: 0; + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.btn-tag-remove:hover { + opacity: 1; +} + +/* Button Group */ + +.btn-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-group-left { + justify-content: flex-start; +} + +.btn-group-center { + justify-content: center; +} + +.btn-group-right { + justify-content: flex-end; +} + +.btn-group-between { + justify-content: space-between; +} + +/* Legacy action-btn compatibility */ + +.action-btn { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* === MODALS === */ + +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + z-index: -1; +} + +.modal-content { + position: relative; + background: #0f1115; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + width: 100%; + max-height: min(92vh, 100%); + display: flex; + flex-direction: column; +} + +/* Modal Sizes */ + +.modal-sm .modal-content { + max-width: 480px; +} + +.modal-md .modal-content { + max-width: 620px; +} + +.modal-lg .modal-content { + max-width: 720px; +} + +.modal-xl .modal-content { + max-width: 960px; +} + +/* Modal Position */ + +.modal-center { + align-items: center; +} + +.modal-top { + align-items: flex-start; + padding-top: 2rem; +} + +/* Modal Scrollable */ + +.modal-scrollable .modal-content { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Modal Structure */ + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-right: 2rem; +} + +.modal-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +/* Modal Variants */ + +.modal-confirm .modal-body { + padding: 1rem 0; + font-size: 0.95rem; +} + +.modal-alert { + text-align: center; +} + +.modal-alert .modal-body { + padding: 1.5rem 0; +} + +.modal-alert .alert-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.modal-alert-info .alert-icon::before { + content: 'ℹ️'; +} + +.modal-alert-success .alert-icon::before { + content: '✅'; +} + +.modal-alert-warning .alert-icon::before { + content: '⚠️'; +} + +.modal-alert-error .alert-icon::before { + content: '❌'; +} + +/* === FORMS === */ + +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.form-label { + font-weight: 500; + font-size: 0.875rem; + color: var(--text); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.form-required { + color: var(--err); + font-weight: bold; +} + +.form-input-wrapper { + display: flex; + flex-direction: column; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-input:disabled, +.form-textarea:disabled, +.form-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-input-number { + max-width: 150px; +} + +.form-input-file { + padding: 0.375rem 0.5rem; +} + +/* Checkbox and Radio */ + +.form-field-checkbox, +.form-field-radio { + flex-direction: row; + align-items: flex-start; +} + +.form-checkbox-label, +.form-radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: normal; +} + +.form-checkbox, +.form-radio { + width: 1.125rem; + height: 1.125rem; + border: 1px solid var(--border); + cursor: pointer; + flex-shrink: 0; +} + +.form-checkbox { + border-radius: 4px; +} + +.form-radio { + border-radius: 50%; +} + +.form-checkbox:checked, +.form-radio:checked { + background: var(--blue-main); + border-color: var(--blue-main); +} + +.form-checkbox:focus, +.form-radio:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Form Help and Error Text */ + +.form-help-text { + font-size: 0.8rem; + color: var(--muted); + margin-top: -0.25rem; +} + +.form-error-text { + font-size: 0.8rem; + color: var(--err); + margin-top: -0.25rem; +} + +.form-field-error .form-input, +.form-field-error .form-textarea, +.form-field-error .form-select { + border-color: var(--err); +} + +/* === CARD DISPLAY COMPONENTS === */ + +/* Card Thumbnail Container */ + +.card-thumb-container { + position: relative; + display: inline-block; +} + +.card-thumb { + display: block; + border-radius: 10px; + border: 1px solid var(--border); + background: #0b0d12; + -o-object-fit: cover; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-thumb:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); +} + +/* Card Thumbnail Sizes */ + +.card-thumb-small .card-thumb { + width: 160px; + height: auto; +} + +.card-thumb-medium .card-thumb { + width: 230px; + height: auto; +} + +.card-thumb-large .card-thumb { + width: 360px; + height: auto; +} + +/* Card Flip Button */ + +.card-flip-btn { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background 0.15s ease; + z-index: 5; +} + +.card-flip-btn:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.4); +} + +.card-flip-btn svg { + width: 16px; + height: 16px; +} + +/* Card Name Label */ + +.card-name-label { + font-size: 0.75rem; + margin-top: 0.375rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + text-align: center; +} + +/* Card Hover Popup */ + +.card-popup { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.card-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); + z-index: -1; +} + +.card-popup-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-width: 400px; + width: 100%; +} + +.card-popup-image { + position: relative; + margin-bottom: 1rem; +} + +.card-popup-image img { + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--border); +} + +.card-popup-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-popup-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.card-popup-role { + font-size: 0.875rem; + color: var(--muted); +} + +.card-popup-role span { + color: var(--text); + font-weight: 500; +} + +.card-popup-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.card-popup-tag { + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; +} + +.card-popup-tag-highlight { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.card-popup-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: none; + border-radius: 6px; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.card-popup-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +/* Card Grid */ + +.card-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.card-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.card-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +.card-grid-cols-5 { + grid-template-columns: repeat(5, 1fr); +} + +.card-grid-cols-6 { + grid-template-columns: repeat(6, 1fr); +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* Card List */ + +.card-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + transition: background 0.15s ease; +} + +.card-list-item:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.card-list-item-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.card-list-item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-list-item-count { + color: var(--muted); + font-size: 0.875rem; +} + +.card-list-item-role { + color: var(--muted); + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +/* Synthetic Card Placeholder */ + +.card-sample.synthetic { + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--panel); + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.synthetic-card-placeholder { + text-align: center; +} + +.synthetic-card-icon { + font-size: 2rem; + opacity: 0.5; + margin-bottom: 0.5rem; +} + +.synthetic-card-name { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.synthetic-card-reason { + font-size: 0.75rem; + color: var(--muted); +} + +/* === PANELS === */ + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.75rem; +} + +/* Panel Variants */ + +.panel-default { + background: var(--panel); +} + +.panel-alt { + background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); +} + +.panel-dark { + background: #0f1115; +} + +.panel-bordered { + background: transparent; +} + +/* Panel Padding */ + +.panel-padding-none { + padding: 0; +} + +.panel-padding-sm { + padding: 0.5rem; +} + +.panel-padding-md { + padding: 0.75rem; +} + +.panel-padding-lg { + padding: 1.5rem; +} + +/* Panel Structure */ + +.panel-header { + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.panel-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.panel-body { + padding: 0.75rem; +} + +.panel-footer { + padding: 0.75rem; + border-top: 1px solid var(--border); +} + +/* Info Panel */ + +.panel-info { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.panel-info-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; +} + +.panel-info-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.panel-info-text { + flex: 1; +} + +.panel-info-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem; + color: var(--text); +} + +.panel-info-message { + font-size: 0.875rem; + color: var(--muted); +} + +.panel-info-action { + flex-shrink: 0; +} + +/* Info Panel Variants */ + +.panel-info-info { + border-color: var(--ring); + background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); +} + +.panel-info-success { + border-color: var(--ok); + background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); +} + +.panel-info-warning { + border-color: var(--warn); + background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); +} + +.panel-info-error { + border-color: var(--err); + background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); +} + +/* Stat Panel */ + +.panel-stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + flex-direction: column; +} + +.panel-stat-icon { + font-size: 2rem; +} + +.panel-stat-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.panel-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: var(--text); +} + +.panel-stat-label { + font-size: 0.875rem; + color: var(--muted); + margin-top: 0.25rem; +} + +.panel-stat-sublabel { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.125rem; +} + +/* Stat Panel Variants */ + +.panel-stat-primary { + border-color: var(--ring); +} + +.panel-stat-primary .panel-stat-value { + color: var(--ring); +} + +.panel-stat-success { + border-color: var(--ok); +} + +.panel-stat-success .panel-stat-value { + color: var(--ok); +} + +.panel-stat-warning { + border-color: var(--warn); +} + +.panel-stat-warning .panel-stat-value { + color: var(--warn); +} + +.panel-stat-error { + border-color: var(--err); +} + +.panel-stat-error .panel-stat-value { + color: var(--err); +} + +/* Collapsible Panel */ + +.panel-collapsible .panel-header { + padding: 0; + border: none; +} + +.panel-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + text-align: left; + border-radius: 10px 10px 0 0; + transition: background 0.15s ease; +} + +.panel-toggle:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.panel-toggle-icon { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid var(--text); + transition: transform 0.2s ease; +} + +.panel-collapsed .panel-toggle-icon { + transform: rotate(-90deg); +} + +.panel-expanded .panel-toggle-icon { + transform: rotate(0deg); +} + +.panel-collapse-content { + overflow: hidden; + transition: max-height 0.3s ease; +} + +/* Panel Grid */ + +.panel-grid { + display: grid; + gap: 1rem; +} + +.panel-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.panel-grid-cols-1 { + grid-template-columns: 1fr; +} + +.panel-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.panel-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.panel-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 768px) { + .panel-grid { + grid-template-columns: 1fr; + } +} + +/* Empty State Panel */ + +.panel-empty-state { + text-align: center; + padding: 3rem 1.5rem; +} + +.panel-empty-icon { + font-size: 4rem; + opacity: 0.5; + margin-bottom: 1rem; +} + +.panel-empty-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--text); +} + +.panel-empty-message { + font-size: 0.95rem; + color: var(--muted); + margin: 0 0 1.5rem; +} + +.panel-empty-action { + display: flex; + justify-content: center; +} + +/* Loading Panel */ + +.panel-loading { + text-align: center; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.panel-loading-spinner { + width: 3rem; + height: 3rem; + border: 4px solid var(--border); + border-top-color: var(--ring); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.panel-loading-message { + font-size: 0.95rem; + color: var(--muted); +} + +/* ============================================================================= + UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) + ============================================================================= */ + +/* Flex Row Layouts */ + +.flex-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.flex-row-sm { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.flex-row-md { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.flex-row-lg { + display: flex; + align-items: center; + gap: 1rem; +} + +.flex-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.flex-row-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.flex-row-start { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* Flex Column Layouts */ + +.flex-col { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.flex-col-sm { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.flex-col-md { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flex-col-lg { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.flex-col-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +/* Flex Grid/Wrap Patterns */ + +.flex-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.flex-grid-sm { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.flex-grid-md { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.flex-grid-lg { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +/* Spacing Utilities */ + +.section-spacing { + margin-top: 2rem; +} + +.section-spacing-sm { + margin-top: 1rem; +} + +.section-spacing-lg { + margin-top: 3rem; +} + +.content-spacing { + margin-bottom: 1rem; +} + +.content-spacing-sm { + margin-bottom: 0.5rem; +} + +.content-spacing-lg { + margin-bottom: 2rem; +} + +/* Common Size Constraints */ + +.max-w-content { + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.max-w-prose { + max-width: 65ch; + margin-left: auto; + margin-right: auto; +} + +.max-w-form { + max-width: 600px; +} + +/* Common Text Patterns */ + +.text-muted { + color: var(--muted); + opacity: 0.85; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1.25; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.35; +} + +.text-base { + font-size: 1rem; + line-height: 1.5; +} + +/* Screen Reader Only */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================================================= + CARD HOVER SYSTEM (Moved from base.html 2025-10-21) + ============================================================================= */ + +.card-hover { + position: fixed; + pointer-events: none; + z-index: 9999; + display: none; +} + +.card-hover-inner { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-hover img { + width: 320px; + height: auto; + display: block; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); + border: 1px solid var(--border); + background: var(--panel); +} + +.card-hover .dual { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-meta { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.6rem; + max-width: 320px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.card-meta ul { + margin: 0.25rem 0; + padding-left: 1.1rem; + list-style: disc; +} + +.card-meta li { + margin: 0.1rem 0; +} + +.card-meta .themes-list { + font-size: 18px; + line-height: 1.35; +} + +.card-meta .label { + color: #94a3b8; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.04em; + display: block; + margin-bottom: 0.15rem; +} + +.card-meta .themes-label { + color: var(--text); + font-size: 20px; + letter-spacing: 0.05em; +} + +.card-meta .line + .line { + margin-top: 0.35rem; +} + +.card-hover .themes-list li.overlap { + color: #0ea5e9; + font-weight: 600; +} + +.card-hover .ov-chip { + display: inline-block; + background: #38bdf8; + color: #102746; + border: 1px solid #0f3a57; + border-radius: 12px; + padding: 2px 6px; + font-size: 11px; + margin-right: 4px; + font-weight: 600; +} + +/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ + +.card-hover .dual.two-faced img { + width: 320px; +} + +.card-hover .dual.two-faced { + gap: 8px; +} + +/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ + +.card-hover .dual.combo img { + width: 300px; +} + +@media (max-width: 1100px) { + .card-hover .dual.two-faced img { + width: 280px; + } + + .card-hover .dual.combo img { + width: 260px; + } +} + +/* Hide hover preview on narrow screens to avoid covering content */ + +@media (max-width: 900px) { + .card-hover { + display: none !important; + } +} + +/* ============================================================================= + THEME BADGES (Moved from base.html 2025-10-21) + ============================================================================= */ + +.theme-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + font-size: 10px; + background: var(--panel-alt); + border: 1px solid var(--border); + letter-spacing: 0.5px; +} + +.theme-synergies { + font-size: 11px; + opacity: 0.85; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.badge-fallback { + background: #7f1d1d; + color: #fff; +} + +.badge-quality-draft { + background: #4338ca; + color: #fff; +} + +.badge-quality-reviewed { + background: #065f46; + color: #fff; +} + +.badge-quality-final { + background: #065f46; + color: #fff; + font-weight: 600; +} + +.badge-pop-vc { + background: #065f46; + color: #fff; +} + +.badge-pop-c { + background: #047857; + color: #fff; +} + +.badge-pop-u { + background: #0369a1; + color: #fff; +} + +.badge-pop-n { + background: #92400e; + color: #fff; +} + +.badge-pop-r { + background: #7f1d1d; + color: #fff; +} + +.badge-curated { + background: #4f46e5; + color: #fff; +} + +.badge-enforced { + background: #334155; + color: #fff; +} + +.badge-inferred { + background: #57534e; + color: #fff; +} + +.theme-detail-card { + background: var(--panel); + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +} + +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +.theme-detail-card h3 { + margin-top: 0; + margin-bottom: 0.4rem; +} + +.theme-detail-card .desc { + margin-top: 0; + font-size: 13px; + line-height: 1.45; +} + +.theme-detail-card h4 { + margin-bottom: 0.35rem; + margin-top: 0.85rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.85; +} + +.breadcrumb { + font-size: 12px; + margin-bottom: 0.4rem; +} + +/* ============================================================================= + HOVER CARD PANEL (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Unified hover-card-panel styling parity */ + +#hover-card-panel.is-payoff { + border-color: var(--accent, #38bdf8); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; +} + +#hover-card-panel.is-payoff .hcp-img { + border-color: var(--accent, #38bdf8); +} + +/* Two-column hover layout */ + +#hover-card-panel .hcp-body { + display: grid; + grid-template-columns: 320px 1fr; + gap: 18px; + align-items: start; +} + +#hover-card-panel .hcp-img-wrap { + grid-column: 1 / 2; +} + +#hover-card-panel.compact-img .hcp-body { + grid-template-columns: 120px 1fr; +} + +#hover-card-panel.hcp-simple { + width: auto !important; + max-width: min(360px, 90vw) !important; + padding: 12px !important; + height: auto !important; + max-height: none !important; + overflow: hidden !important; +} + +#hover-card-panel.hcp-simple .hcp-body { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +#hover-card-panel.hcp-simple .hcp-right { + display: none !important; +} + +#hover-card-panel.hcp-simple .hcp-img { + max-width: 100%; +} + +/* Tag list as multi-column list instead of pill chips for readability */ + +#hover-card-panel .hcp-taglist { + -moz-columns: 2; + columns: 2; + -moz-column-gap: 18px; + column-gap: 18px; + font-size: 13px; + line-height: 1.3; + margin: 6px 0 6px; + padding: 0; + list-style: none; + max-height: 180px; + overflow: auto; +} + +#hover-card-panel .hcp-taglist li { + -moz-column-break-inside: avoid; + break-inside: avoid; + padding: 2px 0 2px 0; + position: relative; +} + +#hover-card-panel .hcp-taglist li.overlap { + font-weight: 600; + color: var(--accent, #38bdf8); +} + +#hover-card-panel .hcp-taglist li.overlap::before { + content: '•'; + color: var(--accent, #38bdf8); + position: absolute; + left: -10px; +} + +#hover-card-panel .hcp-overlaps { + font-size: 10px; + line-height: 1.25; + margin-top: 2px; +} + +#hover-card-panel .hcp-ov-chip { + display: inline-flex; + align-items: center; + background: var(--accent, #38bdf8); + color: #102746; + border: 1px solid rgba(10, 54, 82, 0.6); + border-radius: 9999px; + padding: 3px 10px; + font-size: 13px; + margin-right: 6px; + margin-top: 4px; + font-weight: 500; + letter-spacing: 0.02em; +} + +/* Mobile hover panel */ + +#hover-card-panel.mobile { + left: 50% !important; + top: 50% !important; + bottom: auto !important; + transform: translate(-50%, -50%); + width: min(94vw, 460px) !important; + max-height: 88vh; + overflow-y: auto; + padding: 20px 22px; + pointer-events: auto !important; +} + +#hover-card-panel.mobile .hcp-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +#hover-card-panel.mobile .hcp-img { + width: 100%; + max-width: min(90vw, 420px) !important; + margin: 0 auto; +} + +#hover-card-panel.mobile .hcp-right { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-header { + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-role { + font-size: 12px; + letter-spacing: 0.55px; +} + +#hover-card-panel.mobile .hcp-meta { + font-size: 13px; + text-align: left; +} + +#hover-card-panel.mobile .hcp-overlaps { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { + margin: 0; +} + +#hover-card-panel.mobile .hcp-taglist { + -moz-columns: 1; + columns: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 2px; + max-height: none; + overflow: visible; + padding: 0; +} + +#hover-card-panel.mobile .hcp-taglist li { + background: rgba(37, 99, 235, 0.18); + border-radius: 9999px; + padding: 4px 10px; + display: inline-flex; + align-items: center; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap { + background: rgba(37, 99, 235, 0.28); + color: #dbeafe; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap::before { + display: none; +} + +#hover-card-panel.mobile .hcp-reasons { + max-height: 220px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-tags { + word-break: normal; + white-space: normal; + text-align: left; + width: 100%; + font-size: 12px; + opacity: 0.7; +} + +#hover-card-panel .hcp-close { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; + border-radius: 6px; + display: none; +} + +#hover-card-panel .hcp-close:focus { + outline: 2px solid rgba(59, 130, 246, 0.6); + outline-offset: 2px; +} + +#hover-card-panel.mobile .hcp-close { + display: inline-flex; +} + +/* Fade transition for hover panel image */ + +#hover-card-panel .hcp-img { + transition: opacity 0.22s ease; +} + +/* ============================================================================= + DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Hide modal-specific close button outside modal host */ + +#preview-close-btn { + display: none; +} + +#theme-preview-modal #preview-close-btn { + display: inline-flex; +} + +/* Overlay flip toggle for double-faced cards */ + +.dfc-host { + position: relative; +} + +.dfc-toggle { + position: absolute; + top: 6px; + left: 6px; + z-index: 5; + background: rgba(15, 23, 42, 0.82); + color: #fff; + border: 1px solid #475569; + border-radius: 50%; + width: 36px; + height: 36px; + padding: 0; + font-size: 16px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.92; + backdrop-filter: blur(3px); +} + +.dfc-toggle:hover, +.dfc-toggle:focus { + opacity: 1; + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); + outline: none; +} + +.dfc-toggle:active { + transform: translateY(1px); +} + +.dfc-toggle .icon { + font-size: 12px; +} + +.dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.85); +} + +.dfc-toggle[data-face='front'] { + background: rgba(15, 23, 42, 0.82); +} + +.dfc-toggle[aria-pressed='true'] { + box-shadow: 0 0 0 2px var(--accent, #38bdf8); +} + +.list-row .dfc-toggle { + position: static; + width: auto; + height: auto; + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + opacity: 1; + backdrop-filter: none; + margin-left: 4px; +} + +.list-row .dfc-toggle .icon { + font-size: 12px; +} + +.list-row .dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.3); +} + +.list-row .dfc-toggle[data-face='front'] { + background: rgba(56, 189, 248, 0.2); +} + +/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ + +/* ============================================================================= + SITE FOOTER (Moved from base.html 2025-10-21) + ============================================================================= */ + +.site-footer { + margin: 8px 16px; + padding: 8px 12px; + border-top: 1px solid var(--border); + color: #94a3b8; + font-size: 12px; + text-align: center; +} + +.site-footer a { + color: #cbd5e1; + text-decoration: underline; +} + +/* ============================================================================= + THEME PREVIEW FRAGMENT (themes/preview_fragment.html) + ============================================================================= */ + +/* Preview header */ + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.preview-header h3 { + margin: 0; + font-size: 16px; +} + +.preview-header .btn { + font-size: 12px; + line-height: 1; +} + +/* Preview controls */ + +.preview-controls { + display: flex; + gap: 1rem; + align-items: center; + margin: 0.5rem 0 0.75rem; + font-size: 11px; +} + +.preview-controls label { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.preview-controls .help-icon { + opacity: 0.55; + font-size: 10px; + cursor: help; +} + +.preview-controls #preview-status { + opacity: 0.65; +} + +/* Preview rationale */ + +.preview-rationale { + margin: 0.25rem 0 0.85rem; + font-size: 11px; + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 0.55rem 0.7rem; + border-radius: 8px; +} + +.preview-rationale summary { + cursor: pointer; + font-weight: 600; + letter-spacing: 0.05em; +} + +.preview-rationale-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} + +.preview-rationale-controls .btn { + font-size: 10px; + padding: 4px 8px; +} + +.preview-rationale-controls #hover-compact-indicator { + font-size: 10px; + opacity: 0.7; +} + +.preview-rationale ul { + margin: 0.5rem 0 0 0.9rem; + padding: 0; + list-style: disc; + line-height: 1.35; +} + +.preview-rationale li .detail { + opacity: 0.75; +} + +.preview-rationale li .instances { + opacity: 0.65; +} + +/* Two column layout */ + +.preview-two-col { + display: grid; + grid-template-columns: 1fr 480px; + gap: 1.25rem; + align-items: start; + position: relative; +} + +.preview-col-divider { + position: absolute; + top: 0; + bottom: 0; + left: calc(100% - 480px - 0.75rem); + width: 1px; + background: var(--border); + opacity: 0.55; +} + +/* Section headers */ + +.preview-section-header { + margin: 0.25rem 0 0.5rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.8; +} + +.preview-section-hr { + border: 0; + border-top: 1px solid var(--border); + margin: 0.35rem 0 0.6rem; +} + +/* Cards flow layout */ + +.cards-flow { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Group separators */ + +.group-separator { + flex-basis: 100%; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; + margin-top: 0.25rem; +} + +.group-separator.mt-larger { + margin-top: 0.5rem; +} + +/* Card sample */ + +.card-sample { + width: 230px; +} + +.card-sample .thumb-wrap { + position: relative; +} + +.card-sample img.card-thumb { + filter: blur(4px); + transition: filter 0.35s ease; + background: linear-gradient(145deg, #0b0d12, #111b29); +} + +.card-sample img.card-thumb[data-loaded] { + filter: blur(0); +} + +/* Card badges */ + +.dup-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: #4b5563; + color: #fff; + font-size: 10px; + padding: 2px 5px; + border-radius: 10px; +} + +.pin-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 10px; + padding: 2px 5px; + cursor: pointer; +} + +/* Card metadata */ + +.card-sample .meta { + font-size: 12px; + margin-top: 2px; +} + +.card-sample .ci-ribbon { + display: flex; + gap: 2px; + margin-bottom: 2px; + min-height: 10px; +} + +.card-sample .nm { + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-sample .mana-line { + min-height: 14px; + display: flex; + flex-wrap: wrap; + gap: 2px; + font-size: 10px; +} + +.card-sample .rarity-badge { + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.7; +} + +.card-sample .role { + opacity: 0.75; + font-size: 11px; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.card-sample .reasons { + font-size: 9px; + opacity: 0.55; + line-height: 1.15; +} + +/* Synthetic card */ + +.card-sample.synthetic { + border: 1px dashed var(--border); + padding: 8px; + border-radius: 10px; + background: var(--panel-alt); +} + +.card-sample.synthetic .name { + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.card-sample.synthetic .roles { + font-size: 11px; + opacity: 0.8; +} + +.card-sample.synthetic .reasons-text { + font-size: 10px; + margin-top: 2px; + opacity: 0.6; + line-height: 1.15; +} + +/* Spacer */ + +.full-width-spacer { + flex-basis: 100%; + height: 0; +} + +/* Commander grid */ + +.commander-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1rem; +} + +.commander-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: center; +} + +.commander-name { + font-size: 13px; + text-align: center; + line-height: 1.35; + font-weight: 600; + max-width: 230px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commander-cell.synergy .commander-name { + font-size: 12px; + line-height: 1.3; + font-weight: 500; + opacity: 0.92; +} + +/* Synergy commanders section */ + +.synergy-commanders-section { + margin-top: 1rem; +} + +.synergy-commanders-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} + +.synergy-commanders-header h5 { + margin: 0; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.75; +} + +.derived-badge { + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + line-height: 1; +} + +/* No commanders message */ + +.no-commanders-message { + font-size: 11px; + opacity: 0.7; +} + +/* Footer help text */ + +.preview-help-text { + margin-top: 1rem; + font-size: 10px; + opacity: 0.65; + line-height: 1.4; +} + +/* Skeleton loader */ + +.preview-skeleton .sk-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-skeleton .sk-bar { + height: 16px; + background: var(--hover); + border-radius: 4px; +} + +.preview-skeleton .sk-bar.title { + width: 200px; +} + +.preview-skeleton .sk-bar.close { + width: 60px; +} + +.preview-skeleton .sk-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 1rem; +} + +.preview-skeleton .sk-card { + width: 230px; + height: 327px; + background: var(--hover); + border-radius: 10px; +} + +/* Responsive */ + +@media (max-width: 950px) { + .preview-two-col { + grid-template-columns: 1fr; + } + + .preview-two-col .col-right { + order: -1; + } +} + +footer.site-footer { + flex-shrink: 0; +} + diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css new file mode 100644 index 0000000..94c3b68 --- /dev/null +++ b/code/web/static/tailwind.css @@ -0,0 +1,3500 @@ +/* Tailwind CSS Entry Point */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Import custom CSS (not purged by Tailwind) */ +@import './custom.css'; + +/* Base */ +:root{ + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; /* focus ring */ + --ok: #16a34a; /* success */ + --warn: #f59e0b; /* warning */ + --err: #ef4444; /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); +} + +/* Light blend between Slate and Parchment (leans gray) */ +[data-theme="light-blend"]{ + --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; /* slightly warm muted */ + --border: #d6d1c7; /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; +} + +[data-theme="dark"]{ + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; +} +[data-theme="high-contrast"]{ + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; +} +[data-theme="cb-friendly"]{ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ +} +*{box-sizing:border-box} +html{height:100%; overflow-x:hidden; overflow-y:scroll; max-width:100vw;} +body { + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: scroll; +} +/* Honor HTML hidden attribute across the app */ +[hidden] { display: none !important; } +/* Accessible focus ring for keyboard navigation */ +.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* Top banner - simplified, no changes on sidebar toggle */ +.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); box-shadow:0 2px 6px rgba(0,0,0,.4); min-height: var(--banner-h); } +.top-banner .top-inner{ margin:0; padding:.4rem 15px; display:flex; align-items:center; width:100%; box-sizing:border-box; } +.top-banner h1{ font-size: 1.1rem; margin:0; margin-left: 25px; } +.flex-row{ display: flex; align-items: center; gap: 25px; } +.top-banner .banner-left{ width: 260px !important; flex-shrink: 0 !important; } +/* Hide elements on all screen sizes */ +#btn-open-permalink{ display:none !important; } +#banner-status{ display:none !important; } +.top-banner #theme-reset{ display:none !important; } + +/* Layout */ +.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } +.sidebar{ + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} +.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } + +/* Collapsible sidebar behavior */ +body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } +body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } +body.nav-collapsed .content{ grid-column: 2; } +/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ +/* Smooth hide/show on mobile while keeping fixed positioning */ +.sidebar{ transition: transform .2s ease-out, visibility .2s linear; overflow-x: hidden; } +/* Suppress sidebar transitions during page load to prevent pop-in */ +body.no-transition .sidebar{ transition: none !important; } +/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ +body.htmx-settling .sidebar{ transition: none !important; } +body.htmx-settling .layout{ transition: none !important; } +body.htmx-settling .content{ transition: none !important; } +body.htmx-settling *{ transition-duration: 0s !important; } + +/* Mobile tweaks */ +@media (max-width: 900px){ + :root{ --sidebar-w: 240px; } + .layout{ grid-template-columns: 0 1fr; } + .sidebar{ transform: translateX(-100%); visibility: hidden; } + body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } + body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } + .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } +} + +/* Additional mobile spacing for bottom floating controls */ +@media (max-width: 720px) { + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } +} + +.brand h1{ display:none; } +.brand{ padding-top: 0; margin-top: 0; } +.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; margin-top: 0; padding-top: 0; } +.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } +.dot.green{ background: var(--green-main); } +.dot.blue{ background: var(--blue-main); } +.dot.red{ background: var(--red-main); } +.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } +.dot.black{ background: var(--black-light); } + +.nav{ display:flex; flex-direction:column; gap:.35rem; } +.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } +.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } + +/* Sidebar theme controls anchored at bottom */ +.sidebar .nav { flex: 1 1 auto; } +.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } +.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; flex-wrap: nowrap; } +.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; flex: 1 1 auto; min-width: 0; } +.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); flex-shrink: 0; white-space: nowrap; } + +/* Simple two-column layout for inspect panel */ +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } +.two-col .grow { min-width: 0; } +.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } +@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } + +/* Left-rail variant puts the image first */ +.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } +/* Ensure left-rail variant also collapses to 1 column on small screens */ +@media (max-width: 900px){ + .two-col.two-col-left-rail{ grid-template-columns: 1fr; } + /* So the commander image doesn't dominate on mobile */ + .two-col .card-preview{ max-width: 360px; margin: 0 auto; } + .two-col .card-preview img{ width: 100%; height: auto; } +} +.card-preview.card-sm{ max-width:200px; } + +/* Buttons, inputs */ +button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } +button:hover{ filter:brightness(1.05); } +/* Anchor-style buttons */ +.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } +.btn:hover{ filter:brightness(1.05); text-decoration:none; } +.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } +label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } +select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } +fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } +small, .muted{ color: var(--muted); } +.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } +.partner-preview[hidden]{ display:none !important; } +.partner-preview__header{ font-weight:600; } +.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } +.partner-preview__art{ flex:0 0 auto; } +.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } +.partner-preview__details{ flex:1 1 180px; min-width:0; } +.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } +.partner-preview__pairing{ margin-top:.35rem; } +.partner-preview__themes{ margin-top:.35rem; font-size:12px; } +.partner-preview--static{ margin-bottom:.5rem; } +.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } + +/* Toasts */ +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } + +/* Skeletons */ +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } +[data-skeleton][data-skeleton-overlay="false"]::after, +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } +[data-skeleton]::after{ + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; +} +[data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } +@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } + +/* Banner */ +.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } +.banner h1{ font-size: 2rem; margin:0 0 .35rem; } +.banner .subtitle{ color: var(--muted); font-size:.95rem; } + +/* Home actions */ +.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } +.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } +.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } +.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } + +/* Home page darker buttons */ +.home-button.btn-secondary { + background: #1a1d24; + border-color: #2a2d35; +} +.home-button.btn-secondary:hover { + background: #22252d; + border-color: #3a3d45; +} +.home-button.btn-primary { + background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15)); + border-color: #2a5580; +} +.home-button.btn-primary:hover { + background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25)); + border-color: #3a6590; +} + +/* Card grid for added cards (responsive, compact tiles) */ +.card-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ + gap: .5rem; + margin-top:.5rem; + justify-content: start; /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; +} +@media (max-width: 420px){ + .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-tile{ width: 100%; } + .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } +} +.card-tile{ + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; +} +.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } +.card-tile.locked{ + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; +} +.card-tile.must-include{ + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); +} +.card-tile.must-exclude{ + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; +} +.card-tile.must-include.must-exclude{ + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} +.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } +.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } +.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } + +.must-have-controls{ + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; +} +.must-have-btn{ + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; +} +.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); +} +.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); +} +.must-have-btn:focus-visible{ + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; +} +.card-tile.must-exclude .must-have-btn.include[data-active="0"], +.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ + opacity:.65; +} + +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } +.alt-option{ display:block !important; width:100%; max-width:100%; text-align:left; white-space:normal !important; word-wrap:break-word !important; overflow-wrap:break-word !important; line-height:1.3 !important; padding:0.5rem 0.7rem !important; } + +/* Shared ownership badge for card tiles and stacked images */ +.owned-badge{ + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; +} + +/* Step 1 candidate grid (200px-wide scaled images) */ +.candidate-grid{ + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; +} +.candidate-tile{ + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; +} +.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } +.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } +.candidate-tile .meta{ text-align:center; margin-top:.35rem; } +.candidate-tile .name{ font-weight:600; font-size:.95rem; } +.candidate-tile .score{ color:var(--muted); font-size:.85rem; } + +/* Deck summary: highlight game changers */ +.game-changer { color: var(--green-main); } +.stack-card.game-changer { outline: 2px solid var(--green-main); } + +/* Image button inside card tiles */ +.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } + +/* Stage Navigator */ +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } +.stage-nav .name { font-size:12px; } + +/* Build controls sticky box tweaks */ +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); +} + +@media (max-width: 1024px){ + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } +} +@media (min-width: 721px){ + :root { --banner-offset: 48px; } +} + +/* Progress bar */ +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } + +/* Chips */ +.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } +.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } +.chip:hover { background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); } +.chip.active { + background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); + border-color: #3b82f6; + color: #60a5fa; + font-weight: 600; + box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; +} +.chip.active:hover { + background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); + border-color: #60a5fa; +} + +/* Cards toolbar */ +.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } +.cards-toolbar input[type="text"]{ min-width: 220px; } +.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } +.cards-toolbar .hint{ color: var(--muted); font-size:12px; } + +/* Collapse groups and reason toggle */ +.group{ margin:.5rem 0; } +.group-header{ display:flex; align-items:center; gap:.5rem; } +.group-header h5{ margin:.4rem 0; } +.group-header .count{ color: var(--muted); font-size:12px; } +.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } +.group-grid[data-collapsed]{ display:none; } +.hide-reasons .card-tile .reason{ display:none; } +.card-tile.force-show .reason{ display:block !important; } +.card-tile.force-hide .reason{ display:none !important; } +.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } +.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } +.chips-inline .chip{ cursor:pointer; user-select:none; } + +/* Inline error banner */ +.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } +.inline-error-banner .muted{ color:#fda4af; } + +/* Alternatives panel */ +.alts ul{ list-style:none; padding:0; margin:0; } +.alts li{ display:flex; align-items:center; gap:.4rem; } +/* LQIP blur/fade-in for thumbnails */ +img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } +img.lqip.loaded { filter: blur(0); opacity: 1; } + +/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; } + img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } +} + +/* Virtualization wrapper should mirror grid to keep multi-column flow */ +.virt-wrapper { display: grid; } + +/* Mobile responsive fixes for horizontal scrolling issues */ +@media (max-width: 768px) { + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } + + /* Test hand responsive adjustments */ + #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } + + /* Modal & form layout fixes (original block retained inside media query) */ + /* Fix modal layout on mobile */ + .modal { + padding: 10px !important; + box-sizing: border-box; + } + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + /* Force single column for include/exclude grid */ + .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } + /* Fix basics grid */ + .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } + /* Ensure all inputs and textareas fit properly */ + .modal input, + .modal textarea, + .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } + /* Fix chips containers */ + .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } + /* Ensure fieldsets don't overflow */ + .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } + /* Fix any inline styles that might cause overflow */ + .modal fieldset > div, + .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } +} + +@media (max-width: 640px){ + #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } + /* Generic stack shrink */ + .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } +} + +@media (max-width: 560px){ + #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } + #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } + #test-hand .stack-card{ flex:0 0 auto; } + .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } +} + +@media (max-width: 480px) { + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; /* Much smaller padding */ + font-size: 12px !important; /* Smaller font */ + min-height: 36px !important; /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + .build-controls .sep, + .build-controls .replace-toggle, + .build-controls label[style*="margin-left"] { + display: none !important; + } + + .build-controls .sep { + display: none !important; /* Hide separators on mobile */ + } +} + +/* Desktop sizing for Test Hand */ +@media (min-width: 900px) { + #test-hand { --card-w: 280px !important; --card-h: 392px !important; } +} + +/* Analytics accordion styling */ +.analytics-accordion { + transition: all 0.2s ease; +} + +.analytics-accordion summary { + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.analytics-accordion summary:hover { + background: #1f2937; + border-color: #374151; +} + +.analytics-accordion summary:active { + transform: scale(0.99); +} + +.analytics-accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.analytics-accordion .analytics-content { + animation: accordion-slide-down 0.3s ease-out; +} + +@keyframes accordion-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-placeholder .skeleton-pulse { + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Ideals Slider Styling */ +.ideals-slider { + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; +} + +.ideals-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.ideals-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.slider-value { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Card Details button on tiles */ +.card-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--primary); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: all 0.2s; + margin-top: 0.5rem; + border: none; + cursor: pointer; +} + +.card-details-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); +} + +.card-details-btn svg { + flex-shrink: 0; +} + +/* Card Preview Modal */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + z-index: 9999; + align-items: center; + justify-content: center; +} + +.preview-modal.active { + display: flex; +} + +.preview-content { + position: relative; + max-width: 90%; + max-height: 90%; +} + +.preview-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.preview-close { + position: absolute; + top: -40px; + right: 0; + background: rgba(255, 255, 255, 0.9); + color: #000; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 24px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.preview-close:hover { + background: #fff; + transform: scale(1.1); +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} + +/* Card Detail Page Styles */ +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.card-tag { + background: var(--ring); + color: white; + padding: 0.35rem 0.75rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 500; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--panel); + color: var(--text); + text-decoration: none; + border-radius: 8px; + border: 1px solid var(--border); + font-weight: 500; + transition: all 0.2s; + margin-bottom: 2rem; +} + +.back-button:hover { + background: var(--ring); + color: white; + border-color: var(--ring); +} + +/* Card Detail Page - Main Card Image */ +.card-image-large { + flex: 0 0 auto; + max-width: 360px !important; + width: 100%; +} + +.card-image-large img { + width: 100%; + height: auto; + border-radius: 12px; +} + +/* ============================================ + M2 Component Library Styles + ============================================ */ + +/* === BUTTONS === */ +/* Button Base - enhanced from existing .btn */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--blue-main); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + text-decoration: none; + line-height: 1.5; + font-weight: 500; + transition: filter 0.15s ease, transform 0.05s ease; + white-space: nowrap; +} + +.btn:hover { + filter: brightness(1.1); + text-decoration: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn.disabled, +.btn[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Button Variants */ +.btn-primary { + background: var(--blue-main); + color: #fff; +} + +.btn-secondary { + background: var(--muted); + color: var(--text); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + background: var(--panel); + border-color: var(--text); +} + +.btn-danger { + background: var(--err); + color: #fff; +} + +/* Button Sizes */ +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; +} + +.btn-md { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Icon Button */ +.btn-icon { + padding: 0.5rem; + aspect-ratio: 1; + justify-content: center; +} + +.btn-icon.btn-sm { + padding: 0.25rem; + font-size: 1rem; +} + +/* Close Button */ +.btn-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.5rem; + line-height: 1; + z-index: 10; +} + +/* Tag/Chip Button */ +.btn-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.btn-tag:hover { + background: var(--border); + border-color: var(--text); +} + +.btn-tag-selected { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.btn-tag-remove { + background: transparent; + border: none; + color: inherit; + padding: 0; + margin: 0; + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.btn-tag-remove:hover { + opacity: 1; +} + +/* Button Group */ +.btn-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-group-left { + justify-content: flex-start; +} + +.btn-group-center { + justify-content: center; +} + +.btn-group-right { + justify-content: flex-end; +} + +.btn-group-between { + justify-content: space-between; +} + +/* Legacy action-btn compatibility */ +.action-btn { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* === MODALS === */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + z-index: -1; +} + +.modal-content { + position: relative; + background: #0f1115; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + width: 100%; + max-height: min(92vh, 100%); + display: flex; + flex-direction: column; +} + +/* Modal Sizes */ +.modal-sm .modal-content { + max-width: 480px; +} + +.modal-md .modal-content { + max-width: 620px; +} + +.modal-lg .modal-content { + max-width: 720px; +} + +.modal-xl .modal-content { + max-width: 960px; +} + +/* Modal Position */ +.modal-center { + align-items: center; +} + +.modal-top { + align-items: flex-start; + padding-top: 2rem; +} + +/* Modal Scrollable */ +.modal-scrollable .modal-content { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Modal Structure */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-right: 2rem; +} + +.modal-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.modal-body { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.modal-footer { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +/* Modal Variants */ +.modal-confirm .modal-body { + padding: 1rem 0; + font-size: 0.95rem; +} + +.modal-alert { + text-align: center; +} + +.modal-alert .modal-body { + padding: 1.5rem 0; +} + +.modal-alert .alert-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.modal-alert-info .alert-icon::before { + content: 'ℹ️'; +} + +.modal-alert-success .alert-icon::before { + content: '✅'; +} + +.modal-alert-warning .alert-icon::before { + content: '⚠️'; +} + +.modal-alert-error .alert-icon::before { + content: '❌'; +} + +/* === FORMS === */ +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.form-label { + font-weight: 500; + font-size: 0.875rem; + color: var(--text); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.form-required { + color: var(--err); + font-weight: bold; +} + +.form-input-wrapper { + display: flex; + flex-direction: column; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: var(--ring); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-input:disabled, +.form-textarea:disabled, +.form-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-input-number { + max-width: 150px; +} + +.form-input-file { + padding: 0.375rem 0.5rem; +} + +/* Checkbox and Radio */ +.form-field-checkbox, +.form-field-radio { + flex-direction: row; + align-items: flex-start; +} + +.form-checkbox-label, +.form-radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: normal; +} + +.form-checkbox, +.form-radio { + width: 1.125rem; + height: 1.125rem; + border: 1px solid var(--border); + cursor: pointer; + flex-shrink: 0; +} + +.form-checkbox { + border-radius: 4px; +} + +.form-radio { + border-radius: 50%; +} + +.form-checkbox:checked, +.form-radio:checked { + background: var(--blue-main); + border-color: var(--blue-main); +} + +.form-checkbox:focus, +.form-radio:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); +} + +.form-radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Form Help and Error Text */ +.form-help-text { + font-size: 0.8rem; + color: var(--muted); + margin-top: -0.25rem; +} + +.form-error-text { + font-size: 0.8rem; + color: var(--err); + margin-top: -0.25rem; +} + +.form-field-error .form-input, +.form-field-error .form-textarea, +.form-field-error .form-select { + border-color: var(--err); +} + +/* === CARD DISPLAY COMPONENTS === */ +/* Card Thumbnail Container */ +.card-thumb-container { + position: relative; + display: inline-block; +} + +.card-thumb { + display: block; + border-radius: 10px; + border: 1px solid var(--border); + background: #0b0d12; + object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-thumb:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); +} + +/* Card Thumbnail Sizes */ +.card-thumb-small .card-thumb { + width: 160px; + height: auto; +} + +.card-thumb-medium .card-thumb { + width: 230px; + height: auto; +} + +.card-thumb-large .card-thumb { + width: 360px; + height: auto; +} + +/* Card Flip Button */ +.card-flip-btn { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: background 0.15s ease; + z-index: 5; +} + +.card-flip-btn:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.4); +} + +.card-flip-btn svg { + width: 16px; + height: 16px; +} + +/* Card Name Label */ +.card-name-label { + font-size: 0.75rem; + margin-top: 0.375rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + text-align: center; +} + +/* Card Hover Popup */ +.card-popup { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.card-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); + z-index: -1; +} + +.card-popup-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + padding: 1rem; + max-width: 400px; + width: 100%; +} + +.card-popup-image { + position: relative; + margin-bottom: 1rem; +} + +.card-popup-image img { + width: 100%; + height: auto; + border-radius: 10px; + border: 1px solid var(--border); +} + +.card-popup-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-popup-name { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.card-popup-role { + font-size: 0.875rem; + color: var(--muted); +} + +.card-popup-role span { + color: var(--text); + font-weight: 500; +} + +.card-popup-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.card-popup-tag { + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; +} + +.card-popup-tag-highlight { + background: var(--blue-main); + color: #fff; + border-color: var(--blue-main); +} + +.card-popup-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border: none; + border-radius: 6px; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.card-popup-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +/* Card Grid */ +.card-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +.card-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.card-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.card-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +.card-grid-cols-5 { + grid-template-columns: repeat(5, 1fr); +} + +.card-grid-cols-6 { + grid-template-columns: repeat(6, 1fr); +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* Card List */ +.card-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + transition: background 0.15s ease; +} + +.card-list-item:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.card-list-item-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.card-list-item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-list-item-count { + color: var(--muted); + font-size: 0.875rem; +} + +.card-list-item-role { + color: var(--muted); + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +/* Synthetic Card Placeholder */ +.card-sample.synthetic { + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--panel); + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.synthetic-card-placeholder { + text-align: center; +} + +.synthetic-card-icon { + font-size: 2rem; + opacity: 0.5; + margin-bottom: 0.5rem; +} + +.synthetic-card-name { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.synthetic-card-reason { + font-size: 0.75rem; + color: var(--muted); +} + +/* === PANELS === */ +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.75rem; +} + +/* Panel Variants */ +.panel-default { + background: var(--panel); +} + +.panel-alt { + background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); +} + +.panel-dark { + background: #0f1115; +} + +.panel-bordered { + background: transparent; +} + +/* Panel Padding */ +.panel-padding-none { + padding: 0; +} + +.panel-padding-sm { + padding: 0.5rem; +} + +.panel-padding-md { + padding: 0.75rem; +} + +.panel-padding-lg { + padding: 1.5rem; +} + +/* Panel Structure */ +.panel-header { + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.panel-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text); +} + +.panel-body { + padding: 0.75rem; +} + +.panel-footer { + padding: 0.75rem; + border-top: 1px solid var(--border); +} + +/* Info Panel */ +.panel-info { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.panel-info-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; +} + +.panel-info-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.panel-info-text { + flex: 1; +} + +.panel-info-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem; + color: var(--text); +} + +.panel-info-message { + font-size: 0.875rem; + color: var(--muted); +} + +.panel-info-action { + flex-shrink: 0; +} + +/* Info Panel Variants */ +.panel-info-info { + border-color: var(--ring); + background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); +} + +.panel-info-success { + border-color: var(--ok); + background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); +} + +.panel-info-warning { + border-color: var(--warn); + background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); +} + +.panel-info-error { + border-color: var(--err); + background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); +} + +/* Stat Panel */ +.panel-stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + flex-direction: column; +} + +.panel-stat-icon { + font-size: 2rem; +} + +.panel-stat-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.panel-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1; + color: var(--text); +} + +.panel-stat-label { + font-size: 0.875rem; + color: var(--muted); + margin-top: 0.25rem; +} + +.panel-stat-sublabel { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.125rem; +} + +/* Stat Panel Variants */ +.panel-stat-primary { + border-color: var(--ring); +} + +.panel-stat-primary .panel-stat-value { + color: var(--ring); +} + +.panel-stat-success { + border-color: var(--ok); +} + +.panel-stat-success .panel-stat-value { + color: var(--ok); +} + +.panel-stat-warning { + border-color: var(--warn); +} + +.panel-stat-warning .panel-stat-value { + color: var(--warn); +} + +.panel-stat-error { + border-color: var(--err); +} + +.panel-stat-error .panel-stat-value { + color: var(--err); +} + +/* Collapsible Panel */ +.panel-collapsible .panel-header { + padding: 0; + border: none; +} + +.panel-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + text-align: left; + border-radius: 10px 10px 0 0; + transition: background 0.15s ease; +} + +.panel-toggle:hover { + background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); +} + +.panel-toggle-icon { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 8px solid var(--text); + transition: transform 0.2s ease; +} + +.panel-collapsed .panel-toggle-icon { + transform: rotate(-90deg); +} + +.panel-expanded .panel-toggle-icon { + transform: rotate(0deg); +} + +.panel-collapse-content { + overflow: hidden; + transition: max-height 0.3s ease; +} + +/* Panel Grid */ +.panel-grid { + display: grid; + gap: 1rem; +} + +.panel-grid-cols-auto { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.panel-grid-cols-1 { + grid-template-columns: 1fr; +} + +.panel-grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.panel-grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.panel-grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 768px) { + .panel-grid { + grid-template-columns: 1fr; + } +} + +/* Empty State Panel */ +.panel-empty-state { + text-align: center; + padding: 3rem 1.5rem; +} + +.panel-empty-icon { + font-size: 4rem; + opacity: 0.5; + margin-bottom: 1rem; +} + +.panel-empty-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--text); +} + +.panel-empty-message { + font-size: 0.95rem; + color: var(--muted); + margin: 0 0 1.5rem; +} + +.panel-empty-action { + display: flex; + justify-content: center; +} + +/* Loading Panel */ +.panel-loading { + text-align: center; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.panel-loading-spinner { + width: 3rem; + height: 3rem; + border: 4px solid var(--border); + border-top-color: var(--ring); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.panel-loading-message { + font-size: 0.95rem; + color: var(--muted); +} + +/* ============================================================================= + UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) + ============================================================================= */ + +/* Flex Row Layouts */ +.flex-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.flex-row-sm { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.flex-row-md { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.flex-row-lg { + display: flex; + align-items: center; + gap: 1rem; +} + +.flex-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.flex-row-wrap { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.flex-row-start { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* Flex Column Layouts */ +.flex-col { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.flex-col-sm { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.flex-col-md { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.flex-col-lg { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.flex-col-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +/* Flex Grid/Wrap Patterns */ +.flex-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.flex-grid-sm { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.flex-grid-md { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.flex-grid-lg { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +/* Spacing Utilities */ +.section-spacing { + margin-top: 2rem; +} + +.section-spacing-sm { + margin-top: 1rem; +} + +.section-spacing-lg { + margin-top: 3rem; +} + +.content-spacing { + margin-bottom: 1rem; +} + +.content-spacing-sm { + margin-bottom: 0.5rem; +} + +.content-spacing-lg { + margin-bottom: 2rem; +} + +/* Common Size Constraints */ +.max-w-content { + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +.max-w-prose { + max-width: 65ch; + margin-left: auto; + margin-right: auto; +} + +.max-w-form { + max-width: 600px; +} + +/* Common Text Patterns */ +.text-muted { + color: var(--muted); + opacity: 0.85; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1.25; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.35; +} + +.text-base { + font-size: 1rem; + line-height: 1.5; +} + +/* Screen Reader Only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================================================= + CARD HOVER SYSTEM (Moved from base.html 2025-10-21) + ============================================================================= */ + +.card-hover { + position: fixed; + pointer-events: none; + z-index: 9999; + display: none; +} + +.card-hover-inner { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-hover img { + width: 320px; + height: auto; + display: block; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); + border: 1px solid var(--border); + background: var(--panel); +} + +.card-hover .dual { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.card-meta { + background: var(--panel); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.6rem; + max-width: 320px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.card-meta ul { + margin: 0.25rem 0; + padding-left: 1.1rem; + list-style: disc; +} + +.card-meta li { + margin: 0.1rem 0; +} + +.card-meta .themes-list { + font-size: 18px; + line-height: 1.35; +} + +.card-meta .label { + color: #94a3b8; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.04em; + display: block; + margin-bottom: 0.15rem; +} + +.card-meta .themes-label { + color: var(--text); + font-size: 20px; + letter-spacing: 0.05em; +} + +.card-meta .line + .line { + margin-top: 0.35rem; +} + +.card-hover .themes-list li.overlap { + color: #0ea5e9; + font-weight: 600; +} + +.card-hover .ov-chip { + display: inline-block; + background: #38bdf8; + color: #102746; + border: 1px solid #0f3a57; + border-radius: 12px; + padding: 2px 6px; + font-size: 11px; + margin-right: 4px; + font-weight: 600; +} + +/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ +.card-hover .dual.two-faced img { + width: 320px; +} + +.card-hover .dual.two-faced { + gap: 8px; +} + +/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ +.card-hover .dual.combo img { + width: 300px; +} + +@media (max-width: 1100px) { + .card-hover .dual.two-faced img { + width: 280px; + } + .card-hover .dual.combo img { + width: 260px; + } +} + +/* Hide hover preview on narrow screens to avoid covering content */ +@media (max-width: 900px) { + .card-hover { + display: none !important; + } +} + +/* ============================================================================= + THEME BADGES (Moved from base.html 2025-10-21) + ============================================================================= */ + +.theme-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + font-size: 10px; + background: var(--panel-alt); + border: 1px solid var(--border); + letter-spacing: 0.5px; +} + +.theme-synergies { + font-size: 11px; + opacity: 0.85; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.badge-fallback { + background: #7f1d1d; + color: #fff; +} + +.badge-quality-draft { + background: #4338ca; + color: #fff; +} + +.badge-quality-reviewed { + background: #065f46; + color: #fff; +} + +.badge-quality-final { + background: #065f46; + color: #fff; + font-weight: 600; +} + +.badge-pop-vc { + background: #065f46; + color: #fff; +} + +.badge-pop-c { + background: #047857; + color: #fff; +} + +.badge-pop-u { + background: #0369a1; + color: #fff; +} + +.badge-pop-n { + background: #92400e; + color: #fff; +} + +.badge-pop-r { + background: #7f1d1d; + color: #fff; +} + +.badge-curated { + background: #4f46e5; + color: #fff; +} + +.badge-enforced { + background: #334155; + color: #fff; +} + +.badge-inferred { + background: #57534e; + color: #fff; +} + +.theme-detail-card { + background: var(--panel); + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); +} + +.theme-list-card { + background: var(--panel); + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: background-color 0.15s ease; +} + +.theme-list-card:hover { + background: var(--hover); +} + +.theme-detail-card h3 { + margin-top: 0; + margin-bottom: 0.4rem; +} + +.theme-detail-card .desc { + margin-top: 0; + font-size: 13px; + line-height: 1.45; +} + +.theme-detail-card h4 { + margin-bottom: 0.35rem; + margin-top: 0.85rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.85; +} + +.breadcrumb { + font-size: 12px; + margin-bottom: 0.4rem; +} + +/* ============================================================================= + HOVER CARD PANEL (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Unified hover-card-panel styling parity */ +#hover-card-panel.is-payoff { + border-color: var(--accent, #38bdf8); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; +} + +#hover-card-panel.is-payoff .hcp-img { + border-color: var(--accent, #38bdf8); +} + +/* Two-column hover layout */ +#hover-card-panel .hcp-body { + display: grid; + grid-template-columns: 320px 1fr; + gap: 18px; + align-items: start; +} + +#hover-card-panel .hcp-img-wrap { + grid-column: 1 / 2; +} + +#hover-card-panel.compact-img .hcp-body { + grid-template-columns: 120px 1fr; +} + +#hover-card-panel.hcp-simple { + width: auto !important; + max-width: min(360px, 90vw) !important; + padding: 12px !important; + height: auto !important; + max-height: none !important; + overflow: hidden !important; +} + +#hover-card-panel.hcp-simple .hcp-body { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +#hover-card-panel.hcp-simple .hcp-right { + display: none !important; +} + +#hover-card-panel.hcp-simple .hcp-img { + max-width: 100%; +} + +/* Tag list as multi-column list instead of pill chips for readability */ +#hover-card-panel .hcp-taglist { + columns: 2; + column-gap: 18px; + font-size: 13px; + line-height: 1.3; + margin: 6px 0 6px; + padding: 0; + list-style: none; + max-height: 180px; + overflow: auto; +} + +#hover-card-panel .hcp-taglist li { + break-inside: avoid; + padding: 2px 0 2px 0; + position: relative; +} + +#hover-card-panel .hcp-taglist li.overlap { + font-weight: 600; + color: var(--accent, #38bdf8); +} + +#hover-card-panel .hcp-taglist li.overlap::before { + content: '•'; + color: var(--accent, #38bdf8); + position: absolute; + left: -10px; +} + +#hover-card-panel .hcp-overlaps { + font-size: 10px; + line-height: 1.25; + margin-top: 2px; +} + +#hover-card-panel .hcp-ov-chip { + display: inline-flex; + align-items: center; + background: var(--accent, #38bdf8); + color: #102746; + border: 1px solid rgba(10, 54, 82, 0.6); + border-radius: 9999px; + padding: 3px 10px; + font-size: 13px; + margin-right: 6px; + margin-top: 4px; + font-weight: 500; + letter-spacing: 0.02em; +} + +/* Mobile hover panel */ +#hover-card-panel.mobile { + left: 50% !important; + top: 50% !important; + bottom: auto !important; + transform: translate(-50%, -50%); + width: min(94vw, 460px) !important; + max-height: 88vh; + overflow-y: auto; + padding: 20px 22px; + pointer-events: auto !important; +} + +#hover-card-panel.mobile .hcp-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +#hover-card-panel.mobile .hcp-img { + width: 100%; + max-width: min(90vw, 420px) !important; + margin: 0 auto; +} + +#hover-card-panel.mobile .hcp-right { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-header { + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} + +#hover-card-panel.mobile .hcp-role { + font-size: 12px; + letter-spacing: 0.55px; +} + +#hover-card-panel.mobile .hcp-meta { + font-size: 13px; + text-align: left; +} + +#hover-card-panel.mobile .hcp-overlaps { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { + margin: 0; +} + +#hover-card-panel.mobile .hcp-taglist { + columns: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 2px; + max-height: none; + overflow: visible; + padding: 0; +} + +#hover-card-panel.mobile .hcp-taglist li { + background: rgba(37, 99, 235, 0.18); + border-radius: 9999px; + padding: 4px 10px; + display: inline-flex; + align-items: center; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap { + background: rgba(37, 99, 235, 0.28); + color: #dbeafe; +} + +#hover-card-panel.mobile .hcp-taglist li.overlap::before { + display: none; +} + +#hover-card-panel.mobile .hcp-reasons { + max-height: 220px; + width: 100%; +} + +#hover-card-panel.mobile .hcp-tags { + word-break: normal; + white-space: normal; + text-align: left; + width: 100%; + font-size: 12px; + opacity: 0.7; +} + +#hover-card-panel .hcp-close { + appearance: none; + border: none; + background: transparent; + color: #9ca3af; + font-size: 18px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; + border-radius: 6px; + display: none; +} + +#hover-card-panel .hcp-close:focus { + outline: 2px solid rgba(59, 130, 246, 0.6); + outline-offset: 2px; +} + +#hover-card-panel.mobile .hcp-close { + display: inline-flex; +} + +/* Fade transition for hover panel image */ +#hover-card-panel .hcp-img { + transition: opacity 0.22s ease; +} + +/* ============================================================================= + DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) + ============================================================================= */ + +/* Hide modal-specific close button outside modal host */ +#preview-close-btn { + display: none; +} + +#theme-preview-modal #preview-close-btn { + display: inline-flex; +} + +/* Overlay flip toggle for double-faced cards */ +.dfc-host { + position: relative; +} + +.dfc-toggle { + position: absolute; + top: 6px; + left: 6px; + z-index: 5; + background: rgba(15, 23, 42, 0.82); + color: #fff; + border: 1px solid #475569; + border-radius: 50%; + width: 36px; + height: 36px; + padding: 0; + font-size: 16px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.92; + backdrop-filter: blur(3px); +} + +.dfc-toggle:hover, +.dfc-toggle:focus { + opacity: 1; + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); + outline: none; +} + +.dfc-toggle:active { + transform: translateY(1px); +} + +.dfc-toggle .icon { + font-size: 12px; +} + +.dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.85); +} + +.dfc-toggle[data-face='front'] { + background: rgba(15, 23, 42, 0.82); +} + +.dfc-toggle[aria-pressed='true'] { + box-shadow: 0 0 0 2px var(--accent, #38bdf8); +} + +.list-row .dfc-toggle { + position: static; + width: auto; + height: auto; + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + opacity: 1; + backdrop-filter: none; + margin-left: 4px; +} + +.list-row .dfc-toggle .icon { + font-size: 12px; +} + +.list-row .dfc-toggle[data-face='back'] { + background: rgba(76, 29, 149, 0.3); +} + +.list-row .dfc-toggle[data-face='front'] { + background: rgba(56, 189, 248, 0.2); +} + +/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ + +/* ============================================================================= + SITE FOOTER (Moved from base.html 2025-10-21) + ============================================================================= */ + +.site-footer { + margin: 8px 16px; + padding: 8px 12px; + border-top: 1px solid var(--border); + color: #94a3b8; + font-size: 12px; + text-align: center; +} + +.site-footer a { + color: #cbd5e1; + text-decoration: underline; +} + +/* ============================================================================= + THEME PREVIEW FRAGMENT (themes/preview_fragment.html) + ============================================================================= */ + +/* Preview header */ +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.preview-header h3 { + margin: 0; + font-size: 16px; +} + +.preview-header .btn { + font-size: 12px; + line-height: 1; +} + +/* Preview controls */ +.preview-controls { + display: flex; + gap: 1rem; + align-items: center; + margin: 0.5rem 0 0.75rem; + font-size: 11px; +} + +.preview-controls label { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.preview-controls .help-icon { + opacity: 0.55; + font-size: 10px; + cursor: help; +} + +.preview-controls #preview-status { + opacity: 0.65; +} + +/* Preview rationale */ +.preview-rationale { + margin: 0.25rem 0 0.85rem; + font-size: 11px; + background: var(--panel-alt); + border: 1px solid var(--border); + padding: 0.55rem 0.7rem; + border-radius: 8px; +} + +.preview-rationale summary { + cursor: pointer; + font-weight: 600; + letter-spacing: 0.05em; +} + +.preview-rationale-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} + +.preview-rationale-controls .btn { + font-size: 10px; + padding: 4px 8px; +} + +.preview-rationale-controls #hover-compact-indicator { + font-size: 10px; + opacity: 0.7; +} + +.preview-rationale ul { + margin: 0.5rem 0 0 0.9rem; + padding: 0; + list-style: disc; + line-height: 1.35; +} + +.preview-rationale li .detail { + opacity: 0.75; +} + +.preview-rationale li .instances { + opacity: 0.65; +} + +/* Two column layout */ +.preview-two-col { + display: grid; + grid-template-columns: 1fr 480px; + gap: 1.25rem; + align-items: start; + position: relative; +} + +.preview-col-divider { + position: absolute; + top: 0; + bottom: 0; + left: calc(100% - 480px - 0.75rem); + width: 1px; + background: var(--border); + opacity: 0.55; +} + +/* Section headers */ +.preview-section-header { + margin: 0.25rem 0 0.5rem; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.8; +} + +.preview-section-hr { + border: 0; + border-top: 1px solid var(--border); + margin: 0.35rem 0 0.6rem; +} + +/* Cards flow layout */ +.cards-flow { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* Group separators */ +.group-separator { + flex-basis: 100%; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.65; + margin-top: 0.25rem; +} + +.group-separator.mt-larger { + margin-top: 0.5rem; +} + +/* Card sample */ +.card-sample { + width: 230px; +} + +.card-sample .thumb-wrap { + position: relative; +} + +.card-sample img.card-thumb { + filter: blur(4px); + transition: filter 0.35s ease; + background: linear-gradient(145deg, #0b0d12, #111b29); +} + +.card-sample img.card-thumb[data-loaded] { + filter: blur(0); +} + +/* Card badges */ +.dup-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: #4b5563; + color: #fff; + font-size: 10px; + padding: 2px 5px; + border-radius: 10px; +} + +.pin-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 10px; + padding: 2px 5px; + cursor: pointer; +} + +/* Card metadata */ +.card-sample .meta { + font-size: 12px; + margin-top: 2px; +} + +.card-sample .ci-ribbon { + display: flex; + gap: 2px; + margin-bottom: 2px; + min-height: 10px; +} + +.card-sample .nm { + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-sample .mana-line { + min-height: 14px; + display: flex; + flex-wrap: wrap; + gap: 2px; + font-size: 10px; +} + +.card-sample .rarity-badge { + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + opacity: 0.7; +} + +.card-sample .role { + opacity: 0.75; + font-size: 11px; + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.card-sample .reasons { + font-size: 9px; + opacity: 0.55; + line-height: 1.15; +} + +/* Synthetic card */ +.card-sample.synthetic { + border: 1px dashed var(--border); + padding: 8px; + border-radius: 10px; + background: var(--panel-alt); +} + +.card-sample.synthetic .name { + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.card-sample.synthetic .roles { + font-size: 11px; + opacity: 0.8; +} + +.card-sample.synthetic .reasons-text { + font-size: 10px; + margin-top: 2px; + opacity: 0.6; + line-height: 1.15; +} + +/* Spacer */ +.full-width-spacer { + flex-basis: 100%; + height: 0; +} + +/* Commander grid */ +.commander-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1rem; +} + +.commander-cell { + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: center; +} + +.commander-name { + font-size: 13px; + text-align: center; + line-height: 1.35; + font-weight: 600; + max-width: 230px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commander-cell.synergy .commander-name { + font-size: 12px; + line-height: 1.3; + font-weight: 500; + opacity: 0.92; +} + +/* Synergy commanders section */ +.synergy-commanders-section { + margin-top: 1rem; +} + +.synergy-commanders-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} + +.synergy-commanders-header h5 { + margin: 0; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.75; +} + +.derived-badge { + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + line-height: 1; +} + +/* No commanders message */ +.no-commanders-message { + font-size: 11px; + opacity: 0.7; +} + +/* Footer help text */ +.preview-help-text { + margin-top: 1rem; + font-size: 10px; + opacity: 0.65; + line-height: 1.4; +} + +/* Skeleton loader */ +.preview-skeleton .sk-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-skeleton .sk-bar { + height: 16px; + background: var(--hover); + border-radius: 4px; +} + +.preview-skeleton .sk-bar.title { + width: 200px; +} + +.preview-skeleton .sk-bar.close { + width: 60px; +} + +.preview-skeleton .sk-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 1rem; +} + +.preview-skeleton .sk-card { + width: 230px; + height: 327px; + background: var(--hover); + border-radius: 10px; +} + +/* Responsive */ +@media (max-width: 950px) { + .preview-two-col { + grid-template-columns: 1fr; + } + + .preview-two-col .col-right { + order: -1; + } +} + +footer.site-footer { + flex-shrink: 0; +} + diff --git a/code/web/static/ts/.gitkeep b/code/web/static/ts/.gitkeep new file mode 100644 index 0000000..badfa20 --- /dev/null +++ b/code/web/static/ts/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder for TypeScript source files +# TypeScript files will be compiled to code/web/static/js/ diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 72996c3..f79ae00 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -39,6 +39,7 @@ window.__telemetryEndpoint = '/telemetry/events'; + + + + \ No newline at end of file diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html index cc5277c..7afd820 100644 --- a/code/web/templates/build/_new_deck_tags.html +++ b/code/web/templates/build/_new_deck_tags.html @@ -15,15 +15,15 @@
-
{{ pname }}
+
{{ pname }}
{% if partner_preview_payload %} {% set partner_secondary_name = partner_preview_payload.secondary_name %} {% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %} {% if not partner_image_url and partner_secondary_name %} - {% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %} + {% set partner_image_url = partner_secondary_name|card_image('normal') %} {% endif %} {% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %} {% if not partner_href and partner_secondary_name %} @@ -224,36 +224,83 @@ }); } document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); }); - function updatePartnerRecommendations(tags){ - if (!reco) return; - Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); }); - var unique = []; + + function updatePartnerTags(partnerTags){ + if (!list || !reco) return; + + // Remove old partner-added chips from available list + Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); }); + + // Deduplicate: remove partner tags from recommended section to avoid showing them twice + if (partnerTags && partnerTags.length > 0) { + var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); }); + Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){ + var tag = btn.dataset.tag || ''; + if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) { + btn.remove(); + } + }); + } + + // Get existing tags from the available list (original server-rendered ones) + var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){ + return { + element: b, + tag: (b.dataset.tag || '').trim(), + tagLower: (b.dataset.tag || '').trim().toLowerCase() + }; + }); + + // Build combined list: existing + new partner tags + var combined = []; var seen = new Set(); - (Array.isArray(tags) ? tags : []).forEach(function(tag){ + + // Add existing tags first + existingTags.forEach(function(item){ + if (!item.tag || seen.has(item.tagLower)) return; + seen.add(item.tagLower); + combined.push({ tag: item.tag, element: item.element, isPartner: false }); + }); + + // Add new partner tags + (Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){ var value = String(tag || '').trim(); if (!value) return; var key = value.toLowerCase(); if (seen.has(key)) return; seen.add(key); - unique.push(value); + combined.push({ tag: value, element: null, isPartner: true }); }); - var insertBefore = selAll && selAll.parentElement === reco ? selAll : null; - unique.forEach(function(tag){ - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'chip chip-reco partner-suggestion'; - btn.dataset.tag = tag; - btn.title = 'Synergizes with selected partner pairing'; - btn.textContent = '★ ' + tag; - if (insertBefore){ reco.insertBefore(btn, insertBefore); } - else { reco.appendChild(btn); } + + // Sort alphabetically + combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); }); + + // Re-render the list in sorted order + list.innerHTML = ''; + combined.forEach(function(item){ + if (item.element) { + // Re-append existing element + list.appendChild(item.element); + } else { + // Create new partner-added chip + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chip partner-added'; + btn.dataset.tag = item.tag; + btn.title = 'From combined partner themes'; + btn.textContent = item.tag; + list.appendChild(btn); + } }); - var hasAny = reco.querySelectorAll('button.chip-reco').length > 0; + + // Update visibility of recommended section + var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0; if (recoBlock){ - recoBlock.style.display = hasAny ? '' : 'none'; - recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0'); + recoBlock.style.display = hasAnyReco ? '' : 'none'; + recoBlock.setAttribute('data-has-reco', hasAnyReco ? '1' : '0'); } - if (selAll){ selAll.style.display = hasAny ? '' : 'none'; } + if (selAll){ selAll.style.display = hasAnyReco ? '' : 'none'; } + updateUI(); } @@ -264,11 +311,11 @@ if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){ tags = detail.payload.theme_tags; } - updatePartnerRecommendations(tags); + updatePartnerTags(tags); }); var initialPartnerTags = readPartnerPreviewTags(); - updatePartnerRecommendations(initialPartnerTags); + updatePartnerTags(initialPartnerTags); updateUI(); })(); diff --git a/code/web/templates/build/_partner_controls.html b/code/web/templates/build/_partner_controls.html index 202bf7d..3de6a96 100644 --- a/code/web/templates/build/_partner_controls.html +++ b/code/web/templates/build/_partner_controls.html @@ -106,7 +106,7 @@ {% if partner_preview %} {% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %} {% if not preview_image and partner_preview.secondary_name %} - {% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %} + {% set preview_image = partner_preview.secondary_name|card_image('normal') %} {% endif %} {% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %} {% if not preview_href and partner_preview.secondary_name %} @@ -463,7 +463,7 @@ }; function buildCardImageUrl(name){ if (!name) return ''; - return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal'; + return '/api/images/normal/' + encodeURIComponent(name); } function buildScryfallUrl(name){ if (!name) return ''; @@ -528,7 +528,9 @@ var colorLabel = payload.color_label || ''; var secondaryName = payload.secondary_name || payload.name || ''; var primary = payload.primary_name || primaryName; - var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : []; + // Ensure theme_tags is always an array, even if it comes as a string or other type + var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : + (typeof payload.theme_tags === 'string' ? payload.theme_tags.split(',').map(function(t){ return t.trim(); }).filter(Boolean) : []); var imageUrl = payload.secondary_image_url || payload.image_url || ''; if (!imageUrl && secondaryName){ imageUrl = buildCardImageUrl(secondaryName); diff --git a/code/web/templates/build/_step1.html b/code/web/templates/build/_step1.html index 65e61b8..0f54ecd 100644 --- a/code/web/templates/build/_step1.html +++ b/code/web/templates/build/_step1.html @@ -39,7 +39,7 @@
@@ -77,7 +77,7 @@ {# Strip synergy annotation for Scryfall search and image fuzzy param #} {% set sel_base = (selected.split(' - Synergy (')[0] if ' - Synergy (' in selected else selected) %} - {{ 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 @@ -