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 += '