From a0299fbcfc25f117e279b2edcfc8d7ce3bf24f9c Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 29 Sep 2025 21:32:08 -0700 Subject: [PATCH] feat: align builder commander hover with deck view - reuse shared hover metadata in Step 5 and keep the preview in-app\n- let hover reasons expand without an embedded scrollbar\n- document the hover polish in CHANGELOG and release notes --- CHANGELOG.md | 14 + RELEASE_NOTES_TEMPLATE.md | 18 + code/web/routes/decks.py | 100 +++- code/web/services/build_utils.py | 144 +++++- code/web/services/orchestrator.py | 169 ++++++- code/web/services/summary_utils.py | 133 +++++- code/web/static/app.js | 5 +- code/web/templates/base.html | 304 ++++++++++-- code/web/templates/build/_step5.html | 107 +++-- code/web/templates/decks/view.html | 44 +- code/web/templates/owned/index.html | 14 +- code/web/templates/partials/deck_summary.html | 34 +- .../web/templates/partials/random_result.html | 2 +- config/themes/theme_list.json | 431 +++--------------- 14 files changed, 1046 insertions(+), 473 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c9995..b059c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,25 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Included the tiny `csv_files/testdata` fixture set so CI fast determinism tests have consistent sample data. ### Changed +- Owned Cards library tiles now use larger thumbnails and wider columns, and virtualization only activates when more than 800 cards are present to keep scrolling smooth. - Theme catalog schema now accepts optional `id` values on entries so refreshed catalogs validate cleanly. - CI installs `httpx` with the rest of the web stack and runs pytest via `python -m pytest` so FastAPI tests resolve the local `code` package correctly. - Relaxed fast-path catalog validation to allow empty synergy lists while still warning on missing or malformed data types. +- Deck summary list view now includes inline flip controls for double-faced cards, keeping text mode feature parity with thumbnail mode. +- Hover panel theme chips now highlight only the themes that triggered a card’s inclusion while the full theme list displays as a muted footer without legacy bracket formatting. +- Finished deck summaries now surface overlap chips using sanitized saved metadata with a themed fallback so exported decks match the live builder UI, and hover overlap pills adopt larger, higher-contrast styling on desktop and mobile. +- Builder card tiles now reserve the card art tap/click for previewing; locking is handled exclusively by the dedicated 🔒 button so mobile users can open the hover panel without accidentally changing locks. +- Builder hover tags now surface normalized theme labels (e.g., “Card Advantage”) and suppress internal `creature_add • tag:` prefixes so build-stage pills match the final deck experience. +- Builder Step 5 commander preview now reuses the in-app hover panel (removing the external Scryfall link) and the hover reasons list auto-expands without an embedded scrollbar for easier reading on desktop and mobile. +- Finished deck commander preview now mirrors builder hover behavior with deck-selected overlap chips, the full commander theme list, and suppresses the external Scryfall link so tapping the thumbnail consistently opens the in-app panel across desktop and mobile. ### Fixed +- Hover card role badge is hidden when no role metadata is available, eliminating the empty pill shown in owned library popovers. +- Random Mode fallback warning no longer displays when all theme inputs are blank. +- Reinstated flip controls for double-faced cards in the hover preview and ensured the overlay button stays in sync with card faces. +- Hover card panel adapts for tap-to-open mobile use with centered positioning, readable scaling, and an explicit close control. +- Mobile hover layout now stacks theme chips beneath the artwork for better readability and cleans up theme formatting. +- Duplicate overlap highlighting on desktop hover has been removed; theme pills now render once without stray bullets even when multiple overlaps are present. - Headless runner no longer loops on the power bracket prompt when owned card files exist; scripted responses now auto-select defaults with optional `HEADLESS_USE_OWNED_ONLY` / `HEADLESS_OWNED_SELECTION` overrides for automation flows. - Regenerated `logs/perf/theme_preview_warm_baseline.json` to repair preview performance CI regressions caused by a malformed baseline file and verified the regression gate passes with the refreshed data. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 85f8eb9..c39c9cc 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -5,6 +5,13 @@ - CI updates install the missing `httpx` dependency and run pytest through `python -m` to ensure the web stack tests import the local package correctly. - Fast-path catalog validation now tolerates empty synergy lists while still flagging missing fields or non-string entries. - Committed deterministic CSV fixtures under `csv_files/testdata` so CI random-mode checks have a stable dataset. +- Owned Cards library tiles are larger, virtualization only kicks in for very large libraries, and popovers no longer show empty role pills. +- Random Mode fallback warning remains hidden when no theme filters are provided, keeping Surprise Me runs noise-free. +- Hover previews regained the double-faced card flip button with state synced to the main tile, highlight only the themes that triggered inclusion, and present a mobile-friendly tap layout with centered positioning plus a close control. +- Deck summary text view exposes inline flip toggles for double-faced cards so counts and face switching stay in sync. +- Finished deck summaries now reuse sanitized metadata (with a fallback to deck meta) to display overlap chips that mirror the builder view, and hover overlap pills feature larger, higher-contrast styling for readability. +- Builder Step 5 commander preview now leverages the in-app hover panel (dropping the external Scryfall link) and the hover reasons list expands naturally without a cramped scrollbar. +- Finished deck commander preview now mirrors the builder hover chips, showing deck-selected theme overlaps alongside the full commander theme list while keeping thumbnail taps inside the app instead of bouncing to Scryfall. - Delivered multi-theme random builds with deterministic cascade, strict match support, and polished HTMX/UI flows. - Added opt-in telemetry counters, reroll throttling safeguards, and structured diagnostics exports. - Expanded tooling, documentation, and QA coverage for theme governance, performance profiling, and seed history management. @@ -21,6 +28,17 @@ - Reroll throttle enforcement with banner/countdown messaging plus override hooks for attempts and timeout controls. - Expanded fast tests validating telemetry counters, throttle behavior, and reroll permutations. +### UI polish +- Owned Cards library grid uses 160px thumbnails, wider columns, and defers virtualization until large collections to ensure smooth scrolling. +- Hover card popovers hide the role badge when a card has no role metadata, preventing blank pills in owned library previews. +- Hover card previews now include a flip control for double-faced cards, stay synchronized with the tile button, highlight the active inclusion themes, and present a centered, readable mobile layout when tapped. +- Finished deck exports render the same overlap chips as the live builder, backed by metadata fallback logic, and the hover overlap pills are larger with improved contrast. +- Finished deck commander popovers reuse deck metadata to display overlap chips and commander theme lists, and the commander thumbnail no longer opens Scryfall so tap-to-preview is consistent on mobile. +- Builder stage card tiles now keep the card art tap/click dedicated to previewing while the lock state is controlled solely by the 🔒 button, preventing accidental locks on touch devices. +- Builder hover previews now use normalized theme labels (e.g., “Card Advantage”), suppress internal prefixes so build-mode pills match the finished deck summary, and the Step 5 commander preview stays in-app with hover reasons expanding without scrollbars. +- Deck summary text rows gained inline flip buttons beside card counts to mirror thumbnail behavior for double-faced cards. +- Random Mode fallback banner is suppressed when all theme inputs are empty so Surprise Me runs stay uncluttered. + ### Tooling & docs - Random theme exclusion catalog with reporting script and documentation, alongside a multi-theme performance profiler and regression guard. - Taxonomy snapshot tooling, splash penalty analytics, and governance documentation updated for strict alias and example enforcement. diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 8b8300c..f84d9a1 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -5,11 +5,11 @@ from fastapi.responses import HTMLResponse from pathlib import Path import csv import os -from typing import Dict, List, Tuple, Optional +from typing import Any, Dict, List, Optional, Tuple from ..app import templates -# from ..services import owned_store -from ..services.summary_utils import summary_ctx +from ..services.orchestrator import tags_for_commander +from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx router = APIRouter(prefix="/decks") @@ -264,6 +264,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: summary = None commander_name = '' tags: List[str] = [] + meta_info: Dict[str, Any] = {} sidecar = p.with_suffix('.summary.json') if sidecar.exists(): try: @@ -273,6 +274,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: summary = payload.get('summary') meta = payload.get('meta', {}) if isinstance(meta, dict): + meta_info = meta commander_name = meta.get('commander') or '' _tags = meta.get('tags') or [] if isinstance(_tags, list): @@ -302,7 +304,97 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: "tags": tags, "display_name": display_name, } - ctx.update(summary_ctx(summary=summary, commander=commander_name, tags=tags)) + ctx.update(summary_ctx(summary=summary, commander=commander_name, tags=tags, meta=meta_info)) + + def _extend_sources(values: list[Any], candidate: Any) -> None: + if isinstance(candidate, list): + values.extend(candidate) + elif isinstance(candidate, tuple): + values.extend(list(candidate)) + elif isinstance(candidate, str): + values.append(candidate) + + deck_theme_sources: list[Any] = list(ctx.get("synergies") or tags or []) + if isinstance(meta_info, dict): + for key in ( + "display_themes", + "resolved_themes", + "auto_filled_themes", + "random_display_themes", + "random_resolved_themes", + "random_auto_filled_themes", + "primary_theme", + "secondary_theme", + "tertiary_theme", + ): + _extend_sources(deck_theme_sources, meta_info.get(key)) + deck_theme_tags = format_theme_list(deck_theme_sources) + + commander_theme_sources: list[Any] = [] + if isinstance(meta_info, dict): + for key in ( + "commander_tags", + "commander_theme_tags", + "commander_themes", + "commander_tag_list", + "primary_commander_theme", + "secondary_commander_theme", + ): + _extend_sources(commander_theme_sources, meta_info.get(key)) + commander_meta = meta_info.get("commander", {}) + if isinstance(commander_meta, dict): + _extend_sources(commander_theme_sources, commander_meta.get("tags")) + _extend_sources(commander_theme_sources, commander_meta.get("themes")) + commander_theme_tags = format_theme_list(commander_theme_sources) + if not commander_theme_tags and commander_name: + commander_theme_tags = format_theme_list(tags_for_commander(commander_name)) + + combined_tags: list[str] = [] + combined_seen: set[str] = set() + for collection in (commander_theme_tags, deck_theme_tags): + for label in collection: + key = label.casefold() + if key in combined_seen: + continue + combined_seen.add(key) + combined_tags.append(label) + + overlap_tags: list[str] = [] + overlap_seen: set[str] = set() + combined_keys = {label.casefold() for label in combined_tags} + for label in deck_theme_tags: + key = label.casefold() + if key in combined_keys and key not in overlap_seen: + overlap_tags.append(label) + overlap_seen.add(key) + + commander_tag_slugs = [] + slug_seen: set[str] = set() + for label in combined_tags: + slug = " ".join(str(label or "").strip().lower().split()) + if not slug or slug in slug_seen: + continue + slug_seen.add(slug) + commander_tag_slugs.append(slug) + + reason_bits: list[str] = [] + if deck_theme_tags: + reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags)) + if commander_theme_tags: + reason_bits.append("Commander tags: " + ", ".join(commander_theme_tags)) + commander_reason_text = "; ".join(reason_bits) + + ctx.update( + { + "deck_theme_tags": deck_theme_tags, + "commander_theme_tags": commander_theme_tags, + "commander_combined_tags": combined_tags, + "commander_tag_slugs": commander_tag_slugs, + "commander_reason_text": commander_reason_text, + "commander_overlap_tags": overlap_tags, + "commander_role_label": format_theme_label("Commander"), + } + ) return templates.TemplateResponse("decks/view.html", ctx) diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 9df500b..291a204 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, Iterable, Optional from fastapi import Request from ..services import owned_store from . import orchestrator as orch @@ -91,6 +91,141 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s return ctx +def _extend_sources(target: list[Any], values: Any) -> None: + if not values: + return + if isinstance(values, (list, tuple, set)): + for item in values: + if item is None: + continue + target.append(item) + else: + target.append(values) + + +def commander_hover_context( + commander_name: str | None, + deck_tags: Iterable[Any] | None, + summary: Dict[str, Any] | None, +) -> Dict[str, Any]: + try: + from .summary_utils import format_theme_label, format_theme_list + except Exception: + # Fallbacks in the unlikely event of circular import issues + def format_theme_label(value: Any) -> str: # type: ignore[redef] + text = str(value or "").strip().replace("_", " ") + if not text: + return "" + parts = [] + for chunk in text.split(): + if chunk.isupper(): + parts.append(chunk) + else: + parts.append(chunk[:1].upper() + chunk[1:].lower()) + return " ".join(parts) + + def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef] + seen: set[str] = set() + result: list[str] = [] + for raw in values or []: # type: ignore[arg-type] + label = format_theme_label(raw) + if not label or len(label) <= 1: + continue + key = label.casefold() + if key in seen: + continue + seen.add(key) + result.append(label) + return result + + deck_theme_sources: list[Any] = [] + _extend_sources(deck_theme_sources, list(deck_tags or [])) + meta_info: Dict[str, Any] = {} + if isinstance(summary, dict): + meta_info = summary.get("meta") or {} + if isinstance(meta_info, dict): + for key in ( + "display_themes", + "resolved_themes", + "auto_filled_themes", + "random_display_themes", + "random_resolved_themes", + "random_auto_filled_themes", + "primary_theme", + "secondary_theme", + "tertiary_theme", + ): + _extend_sources(deck_theme_sources, meta_info.get(key)) + deck_theme_tags = format_theme_list(deck_theme_sources) + + commander_theme_sources: list[Any] = [] + if isinstance(meta_info, dict): + for key in ( + "commander_tags", + "commander_theme_tags", + "commander_themes", + "commander_tag_list", + "primary_commander_theme", + "secondary_commander_theme", + ): + _extend_sources(commander_theme_sources, meta_info.get(key)) + commander_meta = meta_info.get("commander") if isinstance(meta_info, dict) else {} + if isinstance(commander_meta, dict): + _extend_sources(commander_theme_sources, commander_meta.get("tags")) + _extend_sources(commander_theme_sources, commander_meta.get("themes")) + + commander_theme_tags = format_theme_list(commander_theme_sources) + if commander_name and not commander_theme_tags: + try: + commander_theme_tags = format_theme_list(orch.tags_for_commander(commander_name)) + except Exception: + commander_theme_tags = [] + + combined_tags: list[str] = [] + combined_seen: set[str] = set() + for source in (commander_theme_tags, deck_theme_tags): + for label in source: + key = label.casefold() + if key in combined_seen: + continue + combined_seen.add(key) + combined_tags.append(label) + + overlap_tags: list[str] = [] + overlap_seen: set[str] = set() + combined_keys = {label.casefold() for label in combined_tags} + for label in deck_theme_tags: + key = label.casefold() + if key in combined_keys and key not in overlap_seen: + overlap_tags.append(label) + overlap_seen.add(key) + + commander_tag_slugs: list[str] = [] + slug_seen: set[str] = set() + for label in combined_tags: + slug = " ".join(str(label or "").strip().lower().split()) + if not slug or slug in slug_seen: + continue + slug_seen.add(slug) + commander_tag_slugs.append(slug) + + reason_bits: list[str] = [] + if deck_theme_tags: + reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags)) + if commander_theme_tags: + reason_bits.append("Commander tags: " + ", ".join(commander_theme_tags)) + + return { + "deck_theme_tags": deck_theme_tags, + "commander_theme_tags": commander_theme_tags, + "commander_combined_tags": combined_tags, + "commander_tag_slugs": commander_tag_slugs, + "commander_overlap_tags": overlap_tags, + "commander_reason_text": "; ".join(reason_bits), + "commander_role_label": format_theme_label("Commander") if commander_name else "", + } + + def step5_ctx_from_result( request: Request, sess: dict, @@ -132,6 +267,13 @@ def step5_ctx_from_result( } if extras: ctx.update(extras) + + hover_meta = commander_hover_context( + commander_name=ctx.get("commander"), + deck_tags=sess.get("tags"), + summary=ctx.get("summary") if ctx.get("summary") else res.get("summary"), + ) + ctx.update(hover_meta) return ctx diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index db99d02..4389225 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -13,6 +13,137 @@ import re import unicodedata from glob import glob +_TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"} +_REASON_SOURCE_OVERRIDES = { + "creature_all_theme": "Theme Match", + "creature_add": "Creature Package", + "creature_fill": "Creature Fill", + "creature_phase": "Creature Stage", + "creatures": "Creature Stage", + "lands": "Lands", + "land_phase": "Land Stage", + "spells": "Spells", + "autocombos": "Combo Package", + "enforcement": "Enforcement", + "lock": "Lock", +} + + +def _humanize_tag_label(tag: Any) -> str: + """Return a human-friendly display label for a tag identifier.""" + try: + raw = str(tag).strip() + except Exception: + raw = "" + if not raw: + return "" + # Replace common separators with spaces and collapse whitespace + cleaned = raw.replace("•", " ") + cleaned = re.sub(r"[_\-]+", " ", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + cleaned = re.sub(r"\s*:\s*", ": ", cleaned) + if not cleaned: + return "" + + words = cleaned.split(" ") + friendly_parts: List[str] = [] + for word in words: + if not word: + continue + upper_word = word.upper() + if upper_word in _TAG_ACRONYM_KEEP or (len(word) <= 3 and word.isupper()): + friendly_parts.append(upper_word) + continue + if word.isupper() or word.islower(): + friendly_parts.append(word.capitalize()) + continue + friendly_parts.append(word[0].upper() + word[1:]) + return " ".join(friendly_parts) + + +def _humanize_reason_source(value: Any) -> str: + try: + raw = str(value).strip() + except Exception: + raw = "" + if not raw: + return "" + key = raw.lower() + if key in _REASON_SOURCE_OVERRIDES: + return _REASON_SOURCE_OVERRIDES[key] + # Split camelCase before normalizing underscores + split_camel = re.sub(r"(? List[str]: + """Split a trigger tag style string into individual tag fragments.""" + if not value: + return [] + try: + raw = str(value) + except Exception: + return [] + parts = re.split(r"[\u2022,;/]+", raw) + return [p.strip() for p in parts if p and p.strip()] + + +def _coerce_tag_iterable(value: Any) -> List[str]: + """Coerce stored tag metadata into a flat list of strings.""" + if isinstance(value, (list, tuple, set)): + out: List[str] = [] + for item in value: + try: + text = str(item).strip() + except Exception: + text = "" + if text: + out.append(text) + return out + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Try JSON decoding first for serialized lists + try: + parsed = json.loads(text) + if isinstance(parsed, (list, tuple, set)): + return [str(item).strip() for item in parsed if str(item).strip()] + except Exception: + pass + parts = re.split(r"[;,]", text) + return [p.strip().strip("'\"") for p in parts if p and p.strip().strip("'\"")] + return [] + + +def _display_tags_from_entry(entry: Dict[str, Any]) -> List[str]: + """Derive a user-facing tag list for a card entry.""" + base_tags = _coerce_tag_iterable(entry.get('Tags')) + trigger_tags = _split_composite_tags(entry.get('TriggerTag')) + combined: List[str] = [] + seen: set[str] = set() + for source in (base_tags, trigger_tags): + for tag in source: + if not tag: + continue + key = str(tag).strip().lower() + if not key or key in seen: + continue + seen.add(key) + friendly = _humanize_tag_label(tag) + if friendly: + combined.append(friendly) + return combined + # --- Theme Metadata Enrichment Helper (Phase D+): ensure editorial scaffolding after any theme export --- def _run_theme_metadata_enrichment(out_func=None) -> None: """Run full metadata enrichment sequence after theme catalog/YAML generation. @@ -2443,14 +2574,38 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal trig = str(entry.get('TriggerTag') or '').strip() parts: list[str] = [] if role: - parts.append(role) + parts.append(_humanize_tag_label(role)) if sub_role: - parts.append(sub_role) - if added_by: - parts.append(f"by {added_by}") + parts.append(_humanize_tag_label(sub_role)) + friendly_added = _humanize_reason_source(added_by) + if friendly_added: + parts.append(friendly_added) + friendly_trig = _humanize_tag_label(trig) if trig: - parts.append(f"tag: {trig}") - reason = " • ".join(parts) + tag_fragment = friendly_trig or str(trig).strip() + if tag_fragment: + parts.append(f"tag: {tag_fragment}") + deduped_parts: list[str] = [] + seen_parts: set[str] = set() + for part in parts: + if not part: + continue + norm = part.strip().lower() + if not norm or norm in seen_parts: + continue + seen_parts.add(norm) + deduped_parts.append(part) + reason = " • ".join(deduped_parts) + display_tags = _display_tags_from_entry(entry) + slug_tags: List[str] = [] + slug_seen: set[str] = set() + for source_list in (_coerce_tag_iterable(entry.get('Tags')), _split_composite_tags(trig)): + for tag_val in source_list: + key_slug = str(tag_val).strip().lower() + if not key_slug or key_slug in slug_seen: + continue + slug_seen.add(key_slug) + slug_tags.append(str(tag_val).strip()) added_cards.append({ "name": name, "count": delta_count, @@ -2458,6 +2613,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "role": role, "sub_role": sub_role, "trigger_tag": trig, + "tags": display_tags, + "tags_slug": slug_tags, }) except Exception: continue diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py index b52c67c..76b5361 100644 --- a/code/web/services/summary_utils.py +++ b/code/web/services/summary_utils.py @@ -1,31 +1,158 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Iterable, List, Optional from deck_builder import builder_constants as bc from .build_utils import owned_set as owned_set_helper from .combo_utils import detect_for_summary as _detect_for_summary +def _sanitize_tag_list(values: Iterable[Any]) -> List[str]: + cleaned: List[str] = [] + for raw in values or []: # type: ignore[arg-type] + text = str(raw or "").strip() + if not text: + continue + if text.startswith("['"): + text = text[2:] + if text.endswith("']") and len(text) >= 2: + text = text[:-2] + if text.startswith('"') and text.endswith('"') and len(text) >= 2: + text = text[1:-1] + if text.startswith("'") and text.endswith("'") and len(text) >= 2: + text = text[1:-1] + text = text.strip(" []\t\n\r") + if not text: + continue + cleaned.append(text) + return cleaned + + +def _normalize_summary_tags(summary: dict[str, Any] | None) -> None: + if not summary: + return + try: + type_breakdown = summary.get("type_breakdown") or {} + cards_by_type = type_breakdown.get("cards") or {} + for clist in cards_by_type.values(): + if not isinstance(clist, list): + continue + for card in clist: + if not isinstance(card, dict): + continue + tags = card.get("tags") or [] + if tags: + card["tags"] = _sanitize_tag_list(tags) + except Exception: + pass + + +def format_theme_label(raw: Any) -> str: + text = str(raw or "").strip() + if not text: + return "" + text = text.replace("_", " ") + words = [] + for part in text.split(): + if not part: + continue + if part.isupper(): + words.append(part) + else: + words.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper()) + return " ".join(words) + + +def format_theme_list(values: Iterable[Any]) -> List[str]: + seen: set[str] = set() + result: List[str] = [] + for raw in values or []: # type: ignore[arg-type] + label = format_theme_label(raw) + if not label: + continue + if len(label) <= 1: + continue + key = label.casefold() + if key in seen: + continue + seen.add(key) + result.append(label) + return result + + def summary_ctx( *, summary: dict | None, commander: str | None = None, tags: list[str] | None = None, + meta: Optional[dict[str, Any]] = None, include_versions: bool = True, ) -> Dict[str, Any]: """Build a unified context payload for deck summary panels. Provides owned_set, game_changers, combos/synergies, and detector versions. """ + _normalize_summary_tags(summary) + det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}} combos = det.get("combos", []) - synergies = det.get("synergies", []) + synergies_raw = det.get("synergies", []) or [] + # Flatten synergy tag names while preserving appearance order and collapsing duplicates case-insensitively + synergy_tags: list[str] = [] + seen: set[str] = set() + for entry in synergies_raw: + if entry is None: + continue + if isinstance(entry, dict): + tags = entry.get("tags", []) or [] + else: + tags = getattr(entry, "tags", None) or [] + for tag in tags: + text = str(tag).strip() + if not text: + continue + key = text.casefold() + if key in seen: + continue + seen.add(key) + synergy_tags.append(text) + if not synergy_tags: + fallback_sources: list[str] = [] + for collection in (tags or []): + fallback_sources.append(str(collection)) + meta_obj = meta or {} + meta_keys = [ + "display_themes", + "resolved_themes", + "auto_filled_themes", + "random_display_themes", + "random_resolved_themes", + "random_auto_filled_themes", + "primary_theme", + "secondary_theme", + "tertiary_theme", + ] + for key in meta_keys: + value = meta_obj.get(key) if isinstance(meta_obj, dict) else None + if isinstance(value, list): + fallback_sources.extend(str(v) for v in value) + elif isinstance(value, str): + fallback_sources.append(value) + for raw in fallback_sources: + label = format_theme_label(raw) + if not label: + continue + key = label.casefold() + if key in seen: + continue + seen.add(key) + synergy_tags.append(label) versions = det.get("versions", {} if include_versions else None) return { "owned_set": owned_set_helper(), "game_changers": bc.GAME_CHANGERS, "combos": combos, - "synergies": synergies, + "synergies": synergy_tags, + "synergy_pairs": synergies_raw, "versions": versions, "commander": commander, "tags": tags or [], diff --git a/code/web/static/app.js b/code/web/static/app.js index 69bb5de..eb129dc 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -286,9 +286,10 @@ tiles.forEach(function(tile){ var name = (tile.getAttribute('data-card-name')||'').toLowerCase(); var role = (tile.getAttribute('data-role')||'').toLowerCase(); - var tags = (tile.getAttribute('data-tags')||'').toLowerCase(); + var tags = (tile.getAttribute('data-tags')||'').toLowerCase(); + var tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase(); var owned = tile.getAttribute('data-owned') === '1'; - var text = name + ' ' + role + ' ' + tags; + var text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug; var qOk = !query || text.indexOf(query) !== -1; var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned); var show = qOk && oOk; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 15a289e..b3a7760 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -154,7 +154,7 @@ .card-hover{ display: none !important; } } .card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; } - .card-hover .ov-chip { display:inline-block; background:#0ea5e91a; color:#0ea5e9; border:1px solid #0ea5e9; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; } + .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; } @@ -178,7 +178,7 @@ #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-block; background:var(--accent,#38bdf8); color:#fff; border:1px solid var(--accent,#38bdf8); border-radius:10px; padding:2px 5px; font-size:9px; margin-right:4px; 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,.6); border-radius:9999px; padding:3px 10px; font-size:13px; margin-right:6px; margin-top:4px; font-weight:500; letter-spacing:.02em; } /* Hide modal-specific close button outside modal host */ #preview-close-btn { display:none; } #theme-preview-modal #preview-close-btn { display:inline-flex; } @@ -191,6 +191,28 @@ .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); } .dfc-toggle[data-face='front'] { background:rgba(15,23,42,.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,.3); } + .list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); } + #hover-card-panel.mobile { left:50% !important; top:auto !important; bottom:max(16px, 5vh); transform:translateX(-50%); width:min(92vw, 420px) !important; max-height:80vh; overflow-y:auto; padding:16px 18px; pointer-events:auto !important; } + #hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:18px; } + #hover-card-panel.mobile .hcp-img { max-width:100%; 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:.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,.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,.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:.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,.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 .22s ease; } .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; } @@ -462,13 +484,14 @@ .filter(function(t){ return t && t.trim(); }); var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; }); var overlapSet = new Set(overlaps); + var highlightOverlapsInList = overlaps.length === 0; if (role || (tags && tags.length)) { var html = ''; if (role) { html += '
Role' + role.replace(/'; } if (tags && tags.length) { - html += '
Themes
    ' + tags.map(function(t){ var safe=t.replace(/' + safe + ''; }).join('') + '
'; + html += '
Themes
    ' + tags.map(function(t){ var safe=t.replace(/' + safe + ''; }).join('') + '
'; if (overlaps.length){ html += '
Overlaps' + overlaps.map(function(o){ return ''+o.replace(/'; }).join(' ') + '
'; } @@ -530,13 +553,27 @@ var LS_PREFIX = 'mtg:face:'; var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches var lastFlip = 0; + var normalize = (window.__normalizeCardName) ? window.__normalizeCardName : function(raw){ + if(!raw) return raw; + var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw); + if(m){ return m[1].trim(); } + return raw; + }; + window.__normalizeCardName = normalize; + function getCardData(card, attr){ + if(!card) return ''; + var val = card.getAttribute(attr); + if(val) return val; + var node = card.querySelector && card.querySelector('['+attr+']'); + return node ? node.getAttribute(attr) : ''; + } function hasTwoFaces(card){ if(!card) return false; - var name = normalizeCardName((card.getAttribute('data-card-name')||'')) + ' ' + normalizeCardName((card.getAttribute('data-original-name')||'')); + var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name')); return name.indexOf('//') > -1; } function keyFor(card){ - var nm = normalizeCardName(card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase(); + var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, 'data-original-name') || '').toLowerCase(); return LS_PREFIX + nm; } function applyStoredFace(card){ @@ -556,7 +593,7 @@ live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite'); document.body.appendChild(live); } - var nm = normalizeCardName(card.getAttribute('data-card-name')||'').split('//')[0].trim(); + var nm = normalize(getCardData(card, 'data-card-name')||'').split('//')[0].trim(); live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm; } function updateButton(btn, face){ @@ -564,10 +601,15 @@ btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face'); btn.innerHTML = ''; } + window.__dfcUpdateButton = updateButton; function ensureButton(card){ if(!hasTwoFaces(card)) return; if(card.querySelector('.dfc-toggle')) return; card.classList.add('dfc-host'); + var resolvedName = getCardData(card, 'data-card-name'); + var resolvedOriginal = getCardData(card, 'data-original-name'); + if(resolvedName && !card.hasAttribute('data-card-name')) card.setAttribute('data-card-name', resolvedName); + if(resolvedOriginal && !card.hasAttribute('data-original-name')) card.setAttribute('data-original-name', resolvedOriginal); applyStoredFace(card); var face = card.getAttribute(FACE_ATTR) || 'front'; var btn = document.createElement('button'); @@ -578,7 +620,22 @@ btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); }); btn.addEventListener('keydown', function(ev){ if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){ ev.preventDefault(); flip(card, btn); }}); updateButton(btn, face); - card.insertBefore(btn, card.firstChild); + if(card.classList.contains('list-row')){ + btn.classList.add('dfc-toggle-inline'); + var slot = card.querySelector('.flip-slot'); + if(slot){ + slot.innerHTML=''; + slot.appendChild(btn); + slot.removeAttribute('aria-hidden'); + } else { + var anchor = card.querySelector('.dfc-anchor'); + if(anchor){ anchor.insertAdjacentElement('afterend', btn); } + else if(card.lastElementChild){ card.insertBefore(btn, card.lastElementChild); } + else { card.appendChild(btn); } + } + } else { + card.insertBefore(btn, card.firstChild); + } } function flip(card, btn){ var now = Date.now(); @@ -594,9 +651,12 @@ announce(next, card); // retrigger hover update under pointer if applicable if(window.__hoverShowCard){ window.__hoverShowCard(card); } + if(window.__dfcNotifyHover){ try{ window.__dfcNotifyHover(card, next); }catch(_){ } } } + window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); }; + window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; }; function scan(){ - document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile').forEach(ensureButton); + document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton); } document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true }); document.addEventListener('DOMContentLoaded', scan); @@ -833,6 +893,7 @@ '
'+ '
 
'+ '
'+ + ''+ '
'+ '
'+ '
'+ @@ -845,7 +906,7 @@ '
'+ '
    '+ '
    '+ - '
      '+ + '
        '+ '
        '+ '
        '+ '
        '; @@ -868,13 +929,54 @@ var metaEl = panel.querySelector('.hcp-meta'); var reasonsList = panel.querySelector('.hcp-reasons'); var tagsEl = panel.querySelector('.hcp-tags'); + var coarseQuery = window.matchMedia('(pointer: coarse)'); + function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; } + function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } } + if(coarseQuery){ + var handler = function(){ refreshPosition(); }; + if(coarseQuery.addEventListener){ coarseQuery.addEventListener('change', handler); } + else if(coarseQuery.addListener){ coarseQuery.addListener(handler); } + } + window.addEventListener('resize', refreshPosition); + var closeBtn = panel.querySelector('.hcp-close'); + if(closeBtn && !closeBtn.__bound){ + closeBtn.__bound = true; + closeBtn.addEventListener('click', function(ev){ ev.preventDefault(); hide(); }); + } + function positionPanel(evt){ + if(isMobileMode()){ + panel.classList.add('mobile'); + var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05)); + panel.style.bottom = bottomOffset + 'px'; + panel.style.left = '50%'; + panel.style.top = 'auto'; + panel.style.right = 'auto'; + panel.style.transform = 'translateX(-50%)'; + panel.style.pointerEvents = 'auto'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad; + var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect(); + if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad; + if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad; + if(x < 8) x = 8; + if(y < 8) y = 8; + panel.style.left = x+'px'; panel.style.top = y+'px'; + panel.style.bottom = 'auto'; + panel.style.right = 'auto'; + } + } function move(evt){ if(panel.style.display==='none') return; - var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad; - var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect(); - if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad; - if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad; - panel.style.left = x+'px'; panel.style.top = y+'px'; + if(!evt){ evt = window.__lastPointerEvent; } + if(!evt && lastCard){ + var rect = lastCard.getBoundingClientRect(); + evt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 }; + } + if(!evt){ evt = { clientX: window.innerWidth/2, clientY: window.innerHeight/2 }; } + positionPanel(evt); } // Lightweight image prefetch LRU cache (size 12) (P2 UI Hover image prefetch) var _imgLRU=[]; @@ -893,57 +995,143 @@ var mana = (attr('data-mana')||'').trim(); var role = (attr('data-role')||'').trim(); var reasonsRaw = attr('data-reasons')||''; - var tags = attr('data-tags')||''; + var tagsRaw = attr('data-tags')||''; + var reasonsRaw = attr('data-reasons')||''; var roleEl = panel.querySelector('.hcp-role'); + var hasFlip = !!card.querySelector('.dfc-toggle'); var tagListEl = panel.querySelector('.hcp-taglist'); var overlapsEl = panel.querySelector('.hcp-overlaps'); var overlapsAttr = attr('data-overlaps') || ''; - var overlapArr = overlapsAttr.split(/\s*,\s*/).filter(Boolean); + function displayLabel(text){ + if(!text) return ''; + var label = String(text); + label = label.replace(/[\u2022\-_]+/g, ' '); + label = label.replace(/\s+/g, ' ').trim(); + return label; + } + function parseTagList(raw){ + if(!raw) return []; + var trimmed = String(raw).trim(); + if(!trimmed) return []; + var result = []; + var candidate = trimmed; + if(trimmed[0] === '[' && trimmed[trimmed.length-1] === ']'){ + candidate = trimmed.slice(1, -1); + } + // Try JSON parsing after normalizing quotes + try { + var normalized = trimmed; + if(trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1){ + normalized = trimmed.replace(/'/g, '"'); + } + var parsed = JSON.parse(normalized); + if(Array.isArray(parsed)){ + result = parsed; + } + } catch(_){ /* fall back below */ } + if(!result || !result.length){ + result = candidate.split(/\s*,\s*/); + } + return result.map(function(t){ return String(t || '').trim(); }).filter(Boolean); + } + function deriveTagsFromReasons(reasons){ + if(!reasons) return []; + // Reasons often include "because it overlaps X, Y" or "by " + var out = []; + // Grab bracketed or quoted lists first + var m = reasons.match(/\[(.*?)\]/); + if(m && m[1]){ out = out.concat(m[1].split(/\s*,\s*/)); } + // Common phrasing: "overlap(s) with A, B" or "by A, B" + var rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig; + var r; + while((r = rx.exec(reasons))){ out = out.concat((r[2]||'').split(/\s*,\s*/)); } + var tagRx = /tag:\s*([^.;]+)/ig; + var tMatch; + while((tMatch = tagRx.exec(reasons))){ out = out.concat((tMatch[1]||'').split(/\s*,\s*/)); } + return out.map(function(s){ return s.trim(); }).filter(Boolean); + } + var overlapArr = []; + if(overlapsAttr){ + var parsedOverlaps = parseTagList(overlapsAttr); + if(parsedOverlaps.length){ overlapArr = parsedOverlaps; } + else { overlapArr = [String(overlapsAttr).trim()]; } + } + var derivedFromReasons = deriveTagsFromReasons(reasonsRaw); + var allTags = parseTagList(tagsRaw); + if(!allTags.length && derivedFromReasons.length){ + // Fallback: try to derive tags from reasons text when tags missing + allTags = derivedFromReasons.slice(); + } + if((!overlapArr || !overlapArr.length) && derivedFromReasons.length){ + var normalizedAll = (allTags||[]).map(function(t){ return { raw: t, key: t.toLowerCase() }; }); + var derivedKeys = new Set(derivedFromReasons.map(function(t){ return t.toLowerCase(); })); + var intersect = normalizedAll.filter(function(entry){ return derivedKeys.has(entry.key); }).map(function(entry){ return entry.raw; }); + if(!intersect.length){ + intersect = derivedFromReasons.slice(); + } + overlapArr = Array.from(new Set(intersect)); + } + overlapArr = (overlapArr||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean); + allTags = (allTags||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean); nameEl.textContent = nm; rarityEl.textContent = rarity; - metaEl.textContent = [role?('Role: '+role):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • '); + var roleLabel = displayLabel(role); + var roleKey = (roleLabel || role || '').toLowerCase(); + var isCommanderRole = roleKey === 'commander'; + metaEl.textContent = [roleLabel?('Role: '+roleLabel):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • '); reasonsList.innerHTML=''; reasonsRaw.split(';').map(function(r){return r.trim();}).filter(Boolean).forEach(function(r){ var li=document.createElement('li'); li.style.margin='2px 0'; li.textContent=r; reasonsList.appendChild(li); }); // Build inline tag list with overlap highlighting if(tagListEl){ tagListEl.innerHTML=''; - if(tags){ - var tagArr = tags.split(/\s*,\s*/).filter(Boolean); - var setOverlap = new Set(overlapArr); - tagArr.forEach(function(t){ - var li = document.createElement('li'); - if(setOverlap.has(t)) li.className='overlap'; - li.textContent = t; - tagListEl.appendChild(li); - }); - } + tagListEl.style.display = 'none'; + tagListEl.setAttribute('aria-hidden','true'); } if(overlapsEl){ - overlapsEl.innerHTML = overlapArr.map(function(o){ return ''+o+''; }).join(''); + if(overlapArr && overlapArr.length){ + overlapsEl.innerHTML = overlapArr.map(function(o){ var label = displayLabel(o); return ''+label+''; }).join(''); + } else { + overlapsEl.innerHTML = ''; + } + } + if(tagsEl){ + if(isCommanderRole){ + tagsEl.textContent = ''; + tagsEl.style.display = 'none'; + } else { + var tagText = allTags.map(displayLabel).join(', '); + tagsEl.textContent = tagText; + tagsEl.style.display = tagText ? '' : 'none'; + } + } + if(roleEl){ + roleEl.textContent = roleLabel || ''; + roleEl.style.display = roleLabel ? 'inline-block' : 'none'; } - tagsEl.textContent = tags; // raw tag string fallback (legacy consumers) - if(roleEl){ roleEl.textContent = role || ''; } panel.classList.toggle('is-payoff', role === 'payoff'); + panel.classList.toggle('is-commander', isCommanderRole); var fuzzy = encodeURIComponent(nm); var rawName = nm || ''; var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1; + if(hasBack) hasFlip = true; var storageKey = 'mtg:face:' + rawName.toLowerCase(); var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })(); if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace); var chosenFace = card.getAttribute('data-current-face') || 'front'; - (function(){ + lastCard = card; + function renderHoverFace(face){ var desiredVersion='large'; - var faceParam = (chosenFace==='back') ? '&face=back' : ''; - var currentKey = nm+':'+chosenFace+':'+desiredVersion; + var faceParam = (face==='back') ? '&face=back' : ''; + var currentKey = nm+':'+face+':'+desiredVersion; var prevFace = imgEl.getAttribute('data-face'); - var faceChanged = prevFace && prevFace !== chosenFace; + var faceChanged = prevFace && prevFace !== face; if(imgEl.getAttribute('data-current')!== currentKey){ var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam; if(faceChanged){ imgEl.style.opacity = 0; } prefetch(src); imgEl.src = src; imgEl.setAttribute('data-current', currentKey); - imgEl.setAttribute('data-face', chosenFace); + imgEl.setAttribute('data-face', face); imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); }); } if(!imgEl.__errBound){ @@ -954,10 +1142,33 @@ else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); } }); } - })(); - panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt); lastCard = card; + } + renderHoverFace(chosenFace); + window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null; + if(evt){ window.__lastPointerEvent = evt; } + if(isMobileMode()){ + panel.classList.add('mobile'); + panel.style.pointerEvents = 'auto'; + panel.style.maxHeight = '80vh'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.maxHeight = ''; + panel.style.bottom = 'auto'; + } + panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt); + } + function hide(){ + panel.style.display='none'; + panel.setAttribute('aria-hidden','true'); + cancelSchedule(); + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + panel.style.bottom = 'auto'; + panel.style.maxHeight = ''; + window.__dfcNotifyHover = null; } - function hide(){ panel.style.display='none'; panel.setAttribute('aria-hidden','true'); cancelSchedule(); } document.addEventListener('mousemove', move); function getCardFromEl(el){ if(!el) return null; @@ -978,6 +1189,7 @@ } document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }); document.addEventListener('pointerover', function(e){ + if(isMobileMode()) return; var card = getCardFromEl(e.target); if(!card) return; // If hovering flip button, refresh immediately (no activation delay) @@ -989,6 +1201,7 @@ schedule(card, e); }); document.addEventListener('pointerout', function(e){ + if(isMobileMode()) return; var relCard = getCardFromEl(e.relatedTarget); if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button) if(!panel.contains(e.relatedTarget)){ @@ -996,6 +1209,21 @@ if(!relCard) hide(); } }); + document.addEventListener('click', function(e){ + if(!isMobileMode()) return; + if(panel.contains(e.target)) return; + if(e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return; + if(e.target.closest && e.target.closest('button, input, select, textarea, a')) return; + var card = getCardFromEl(e.target); + if(card){ + cancelSchedule(); + var rect = card.getBoundingClientRect(); + var syntheticEvt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 }; + show(card, syntheticEvt); + } else if(panel.style.display==='block'){ + hide(); + } + }); // Expose show function for external refresh (flip updates) window.__hoverShowCard = function(card){ var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) }; diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 164de34..829b12e 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -3,12 +3,39 @@