mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
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
This commit is contained in:
parent
b0080ed482
commit
a0299fbcfc
14 changed files with 1046 additions and 473 deletions
14
CHANGELOG.md
14
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.
|
- Included the tiny `csv_files/testdata` fixture set so CI fast determinism tests have consistent sample data.
|
||||||
|
|
||||||
### Changed
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Tooling & docs
|
||||||
- Random theme exclusion catalog with reporting script and documentation, alongside a multi-theme performance profiler and regression guard.
|
- 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.
|
- Taxonomy snapshot tooling, splash penalty analytics, and governance documentation updated for strict alias and example enforcement.
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ from fastapi.responses import HTMLResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, Tuple, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from ..app import templates
|
from ..app import templates
|
||||||
# from ..services import owned_store
|
from ..services.orchestrator import tags_for_commander
|
||||||
from ..services.summary_utils import summary_ctx
|
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/decks")
|
router = APIRouter(prefix="/decks")
|
||||||
|
|
@ -264,6 +264,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
summary = None
|
summary = None
|
||||||
commander_name = ''
|
commander_name = ''
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
|
meta_info: Dict[str, Any] = {}
|
||||||
sidecar = p.with_suffix('.summary.json')
|
sidecar = p.with_suffix('.summary.json')
|
||||||
if sidecar.exists():
|
if sidecar.exists():
|
||||||
try:
|
try:
|
||||||
|
|
@ -273,6 +274,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
summary = payload.get('summary')
|
summary = payload.get('summary')
|
||||||
meta = payload.get('meta', {})
|
meta = payload.get('meta', {})
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
|
meta_info = meta
|
||||||
commander_name = meta.get('commander') or ''
|
commander_name = meta.get('commander') or ''
|
||||||
_tags = meta.get('tags') or []
|
_tags = meta.get('tags') or []
|
||||||
if isinstance(_tags, list):
|
if isinstance(_tags, list):
|
||||||
|
|
@ -302,7 +304,97 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"display_name": display_name,
|
"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)
|
return templates.TemplateResponse("decks/view.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Iterable, Optional
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from ..services import owned_store
|
from ..services import owned_store
|
||||||
from . import orchestrator as orch
|
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
|
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(
|
def step5_ctx_from_result(
|
||||||
request: Request,
|
request: Request,
|
||||||
sess: dict,
|
sess: dict,
|
||||||
|
|
@ -132,6 +267,13 @@ def step5_ctx_from_result(
|
||||||
}
|
}
|
||||||
if extras:
|
if extras:
|
||||||
ctx.update(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
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,137 @@ import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from glob import glob
|
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"(?<!^)([A-Z])", r" \1", raw).replace("-", " ")
|
||||||
|
cleaned = split_camel.replace("_", " ")
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
stopwords = {"all", "step", "phase", "pkg", "package", "stage"}
|
||||||
|
tokens = [t for t in cleaned.split(" ") if t]
|
||||||
|
filtered = [t for t in tokens if t.lower() not in stopwords]
|
||||||
|
base = " ".join(filtered if filtered else tokens)
|
||||||
|
friendly = _humanize_tag_label(base)
|
||||||
|
return friendly
|
||||||
|
|
||||||
|
|
||||||
|
def _split_composite_tags(value: Any) -> 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 ---
|
# --- Theme Metadata Enrichment Helper (Phase D+): ensure editorial scaffolding after any theme export ---
|
||||||
def _run_theme_metadata_enrichment(out_func=None) -> None:
|
def _run_theme_metadata_enrichment(out_func=None) -> None:
|
||||||
"""Run full metadata enrichment sequence after theme catalog/YAML generation.
|
"""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()
|
trig = str(entry.get('TriggerTag') or '').strip()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if role:
|
if role:
|
||||||
parts.append(role)
|
parts.append(_humanize_tag_label(role))
|
||||||
if sub_role:
|
if sub_role:
|
||||||
parts.append(sub_role)
|
parts.append(_humanize_tag_label(sub_role))
|
||||||
if added_by:
|
friendly_added = _humanize_reason_source(added_by)
|
||||||
parts.append(f"by {added_by}")
|
if friendly_added:
|
||||||
|
parts.append(friendly_added)
|
||||||
|
friendly_trig = _humanize_tag_label(trig)
|
||||||
if trig:
|
if trig:
|
||||||
parts.append(f"tag: {trig}")
|
tag_fragment = friendly_trig or str(trig).strip()
|
||||||
reason = " • ".join(parts)
|
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({
|
added_cards.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"count": delta_count,
|
"count": delta_count,
|
||||||
|
|
@ -2458,6 +2613,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
"role": role,
|
"role": role,
|
||||||
"sub_role": sub_role,
|
"sub_role": sub_role,
|
||||||
"trigger_tag": trig,
|
"trigger_tag": trig,
|
||||||
|
"tags": display_tags,
|
||||||
|
"tags_slug": slug_tags,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,158 @@
|
||||||
from __future__ import annotations
|
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 deck_builder import builder_constants as bc
|
||||||
from .build_utils import owned_set as owned_set_helper
|
from .build_utils import owned_set as owned_set_helper
|
||||||
from .combo_utils import detect_for_summary as _detect_for_summary
|
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(
|
def summary_ctx(
|
||||||
*,
|
*,
|
||||||
summary: dict | None,
|
summary: dict | None,
|
||||||
commander: str | None = None,
|
commander: str | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
|
meta: Optional[dict[str, Any]] = None,
|
||||||
include_versions: bool = True,
|
include_versions: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Build a unified context payload for deck summary panels.
|
"""Build a unified context payload for deck summary panels.
|
||||||
|
|
||||||
Provides owned_set, game_changers, combos/synergies, and detector versions.
|
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": {}}
|
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
|
||||||
combos = det.get("combos", [])
|
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)
|
versions = det.get("versions", {} if include_versions else None)
|
||||||
return {
|
return {
|
||||||
"owned_set": owned_set_helper(),
|
"owned_set": owned_set_helper(),
|
||||||
"game_changers": bc.GAME_CHANGERS,
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
"combos": combos,
|
"combos": combos,
|
||||||
"synergies": synergies,
|
"synergies": synergy_tags,
|
||||||
|
"synergy_pairs": synergies_raw,
|
||||||
"versions": versions,
|
"versions": versions,
|
||||||
"commander": commander,
|
"commander": commander,
|
||||||
"tags": tags or [],
|
"tags": tags or [],
|
||||||
|
|
|
||||||
|
|
@ -286,9 +286,10 @@
|
||||||
tiles.forEach(function(tile){
|
tiles.forEach(function(tile){
|
||||||
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
|
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
|
||||||
var role = (tile.getAttribute('data-role')||'').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 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 qOk = !query || text.indexOf(query) !== -1;
|
||||||
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
|
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
|
||||||
var show = qOk && oOk;
|
var show = qOk && oOk;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@
|
||||||
.card-hover{ display: none !important; }
|
.card-hover{ display: none !important; }
|
||||||
}
|
}
|
||||||
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
|
.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 */
|
/* 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 img { width:320px; }
|
||||||
.card-hover .dual.two-faced { gap:8px; }
|
.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 { 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-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-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 */
|
/* Hide modal-specific close button outside modal host */
|
||||||
#preview-close-btn { display:none; }
|
#preview-close-btn { display:none; }
|
||||||
#theme-preview-modal #preview-close-btn { display:inline-flex; }
|
#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='back'] { background:rgba(76,29,149,.85); }
|
||||||
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
|
.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); }
|
.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 */
|
/* Fade transition for hover panel image */
|
||||||
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
|
#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; }
|
.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(); });
|
.filter(function(t){ return t && t.trim(); });
|
||||||
var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; });
|
var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; });
|
||||||
var overlapSet = new Set(overlaps);
|
var overlapSet = new Set(overlaps);
|
||||||
|
var highlightOverlapsInList = overlaps.length === 0;
|
||||||
if (role || (tags && tags.length)) {
|
if (role || (tags && tags.length)) {
|
||||||
var html = '';
|
var html = '';
|
||||||
if (role) {
|
if (role) {
|
||||||
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
||||||
}
|
}
|
||||||
if (tags && tags.length) {
|
if (tags && tags.length) {
|
||||||
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ var safe=t.replace(/</g,'<'); return '<li'+(overlapSet.has(t)?' class="overlap"':'')+'>' + safe + '</li>'; }).join('') + '</ul></div>';
|
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ var safe=t.replace(/</g,'<'); var isOverlap = overlapSet.has(t); return '<li' + ((highlightOverlapsInList && isOverlap) ? ' class="overlap"' : '') + '>' + safe + '</li>'; }).join('') + '</ul></div>';
|
||||||
if (overlaps.length){
|
if (overlaps.length){
|
||||||
html += '<div class="line" style="margin-top:4px;"><span class="label" title="Themes shared with preview selection">Overlaps</span>' + overlaps.map(function(o){ return '<span class="ov-chip">'+o.replace(/</g,'<')+'</span>'; }).join(' ') + '</div>';
|
html += '<div class="line" style="margin-top:4px;"><span class="label" title="Themes shared with preview selection">Overlaps</span>' + overlaps.map(function(o){ return '<span class="ov-chip">'+o.replace(/</g,'<')+'</span>'; }).join(' ') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
@ -530,13 +553,27 @@
|
||||||
var LS_PREFIX = 'mtg:face:';
|
var LS_PREFIX = 'mtg:face:';
|
||||||
var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches
|
var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches
|
||||||
var lastFlip = 0;
|
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){
|
function hasTwoFaces(card){
|
||||||
if(!card) return false;
|
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;
|
return name.indexOf('//') > -1;
|
||||||
}
|
}
|
||||||
function keyFor(card){
|
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;
|
return LS_PREFIX + nm;
|
||||||
}
|
}
|
||||||
function applyStoredFace(card){
|
function applyStoredFace(card){
|
||||||
|
|
@ -556,7 +593,7 @@
|
||||||
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
|
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
|
||||||
document.body.appendChild(live);
|
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;
|
live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm;
|
||||||
}
|
}
|
||||||
function updateButton(btn, face){
|
function updateButton(btn, face){
|
||||||
|
|
@ -564,10 +601,15 @@
|
||||||
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
|
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
|
||||||
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
||||||
}
|
}
|
||||||
|
window.__dfcUpdateButton = updateButton;
|
||||||
function ensureButton(card){
|
function ensureButton(card){
|
||||||
if(!hasTwoFaces(card)) return;
|
if(!hasTwoFaces(card)) return;
|
||||||
if(card.querySelector('.dfc-toggle')) return;
|
if(card.querySelector('.dfc-toggle')) return;
|
||||||
card.classList.add('dfc-host');
|
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);
|
applyStoredFace(card);
|
||||||
var face = card.getAttribute(FACE_ATTR) || 'front';
|
var face = card.getAttribute(FACE_ATTR) || 'front';
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
|
|
@ -578,7 +620,22 @@
|
||||||
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
|
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); }});
|
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);
|
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){
|
function flip(card, btn){
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
|
|
@ -594,9 +651,12 @@
|
||||||
announce(next, card);
|
announce(next, card);
|
||||||
// retrigger hover update under pointer if applicable
|
// retrigger hover update under pointer if applicable
|
||||||
if(window.__hoverShowCard){ window.__hoverShowCard(card); }
|
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(){
|
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('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
|
||||||
document.addEventListener('DOMContentLoaded', scan);
|
document.addEventListener('DOMContentLoaded', scan);
|
||||||
|
|
@ -833,6 +893,7 @@
|
||||||
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">'+
|
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">'+
|
||||||
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>'+
|
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>'+
|
||||||
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
|
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
|
||||||
|
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="hcp-body">'+
|
'<div class="hcp-body">'+
|
||||||
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
|
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
|
||||||
|
|
@ -845,7 +906,7 @@
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
|
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
|
||||||
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>'+
|
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>'+
|
||||||
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;max-height:140px;overflow:auto;font-size:11px;line-height:1.35;"></ul>'+
|
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;font-size:11px;line-height:1.35;"></ul>'+
|
||||||
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>'+
|
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
@ -868,13 +929,54 @@
|
||||||
var metaEl = panel.querySelector('.hcp-meta');
|
var metaEl = panel.querySelector('.hcp-meta');
|
||||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||||
var tagsEl = panel.querySelector('.hcp-tags');
|
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){
|
function move(evt){
|
||||||
if(panel.style.display==='none') return;
|
if(panel.style.display==='none') return;
|
||||||
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
|
if(!evt){ evt = window.__lastPointerEvent; }
|
||||||
var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect();
|
if(!evt && lastCard){
|
||||||
if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
|
var rect = lastCard.getBoundingClientRect();
|
||||||
if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
|
evt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 };
|
||||||
panel.style.left = x+'px'; panel.style.top = y+'px';
|
}
|
||||||
|
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)
|
// Lightweight image prefetch LRU cache (size 12) (P2 UI Hover image prefetch)
|
||||||
var _imgLRU=[];
|
var _imgLRU=[];
|
||||||
|
|
@ -893,57 +995,143 @@
|
||||||
var mana = (attr('data-mana')||'').trim();
|
var mana = (attr('data-mana')||'').trim();
|
||||||
var role = (attr('data-role')||'').trim();
|
var role = (attr('data-role')||'').trim();
|
||||||
var reasonsRaw = attr('data-reasons')||'';
|
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 roleEl = panel.querySelector('.hcp-role');
|
||||||
|
var hasFlip = !!card.querySelector('.dfc-toggle');
|
||||||
var tagListEl = panel.querySelector('.hcp-taglist');
|
var tagListEl = panel.querySelector('.hcp-taglist');
|
||||||
var overlapsEl = panel.querySelector('.hcp-overlaps');
|
var overlapsEl = panel.querySelector('.hcp-overlaps');
|
||||||
var overlapsAttr = attr('data-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 <theme>"
|
||||||
|
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;
|
nameEl.textContent = nm;
|
||||||
rarityEl.textContent = rarity;
|
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='';
|
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); });
|
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
|
// Build inline tag list with overlap highlighting
|
||||||
if(tagListEl){
|
if(tagListEl){
|
||||||
tagListEl.innerHTML='';
|
tagListEl.innerHTML='';
|
||||||
if(tags){
|
tagListEl.style.display = 'none';
|
||||||
var tagArr = tags.split(/\s*,\s*/).filter(Boolean);
|
tagListEl.setAttribute('aria-hidden','true');
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if(overlapsEl){
|
if(overlapsEl){
|
||||||
overlapsEl.innerHTML = overlapArr.map(function(o){ return '<span class="hcp-ov-chip" title="Overlapping synergy">'+o+'</span>'; }).join('');
|
if(overlapArr && overlapArr.length){
|
||||||
|
overlapsEl.innerHTML = overlapArr.map(function(o){ var label = displayLabel(o); return '<span class="hcp-ov-chip" title="Overlapping synergy">'+label+'</span>'; }).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-payoff', role === 'payoff');
|
||||||
|
panel.classList.toggle('is-commander', isCommanderRole);
|
||||||
var fuzzy = encodeURIComponent(nm);
|
var fuzzy = encodeURIComponent(nm);
|
||||||
var rawName = nm || '';
|
var rawName = nm || '';
|
||||||
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
||||||
|
if(hasBack) hasFlip = true;
|
||||||
var storageKey = 'mtg:face:' + rawName.toLowerCase();
|
var storageKey = 'mtg:face:' + rawName.toLowerCase();
|
||||||
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
|
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
|
||||||
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
|
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
|
||||||
var chosenFace = card.getAttribute('data-current-face') || 'front';
|
var chosenFace = card.getAttribute('data-current-face') || 'front';
|
||||||
(function(){
|
lastCard = card;
|
||||||
|
function renderHoverFace(face){
|
||||||
var desiredVersion='large';
|
var desiredVersion='large';
|
||||||
var faceParam = (chosenFace==='back') ? '&face=back' : '';
|
var faceParam = (face==='back') ? '&face=back' : '';
|
||||||
var currentKey = nm+':'+chosenFace+':'+desiredVersion;
|
var currentKey = nm+':'+face+':'+desiredVersion;
|
||||||
var prevFace = imgEl.getAttribute('data-face');
|
var prevFace = imgEl.getAttribute('data-face');
|
||||||
var faceChanged = prevFace && prevFace !== chosenFace;
|
var faceChanged = prevFace && prevFace !== face;
|
||||||
if(imgEl.getAttribute('data-current')!== currentKey){
|
if(imgEl.getAttribute('data-current')!== currentKey){
|
||||||
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
|
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
|
||||||
if(faceChanged){ imgEl.style.opacity = 0; }
|
if(faceChanged){ imgEl.style.opacity = 0; }
|
||||||
prefetch(src);
|
prefetch(src);
|
||||||
imgEl.src = src;
|
imgEl.src = src;
|
||||||
imgEl.setAttribute('data-current', currentKey);
|
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; }); });
|
imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); });
|
||||||
}
|
}
|
||||||
if(!imgEl.__errBound){
|
if(!imgEl.__errBound){
|
||||||
|
|
@ -954,10 +1142,33 @@
|
||||||
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
|
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);
|
document.addEventListener('mousemove', move);
|
||||||
function getCardFromEl(el){
|
function getCardFromEl(el){
|
||||||
if(!el) return null;
|
if(!el) return null;
|
||||||
|
|
@ -978,6 +1189,7 @@
|
||||||
}
|
}
|
||||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
|
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
|
||||||
document.addEventListener('pointerover', function(e){
|
document.addEventListener('pointerover', function(e){
|
||||||
|
if(isMobileMode()) return;
|
||||||
var card = getCardFromEl(e.target);
|
var card = getCardFromEl(e.target);
|
||||||
if(!card) return;
|
if(!card) return;
|
||||||
// If hovering flip button, refresh immediately (no activation delay)
|
// If hovering flip button, refresh immediately (no activation delay)
|
||||||
|
|
@ -989,6 +1201,7 @@
|
||||||
schedule(card, e);
|
schedule(card, e);
|
||||||
});
|
});
|
||||||
document.addEventListener('pointerout', function(e){
|
document.addEventListener('pointerout', function(e){
|
||||||
|
if(isMobileMode()) return;
|
||||||
var relCard = getCardFromEl(e.relatedTarget);
|
var relCard = getCardFromEl(e.relatedTarget);
|
||||||
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
|
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
|
||||||
if(!panel.contains(e.relatedTarget)){
|
if(!panel.contains(e.relatedTarget)){
|
||||||
|
|
@ -996,6 +1209,21 @@
|
||||||
if(!relCard) hide();
|
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)
|
// Expose show function for external refresh (flip updates)
|
||||||
window.__hoverShowCard = function(card){
|
window.__hoverShowCard = function(card){
|
||||||
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
|
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,39 @@
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview">
|
<aside class="card-preview">
|
||||||
{# Strip synergy annotation for Scryfall search #}
|
{# Strip synergy annotation for Scryfall search #}
|
||||||
<a href="https://scryfall.com/search?q={{ (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander)|urlencode }}" target="_blank" rel="noopener">
|
{% if commander %}
|
||||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" loading="lazy" decoding="async" data-lqip="1"
|
<div class="commander-card" tabindex="0"
|
||||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=large 672w"
|
data-card-name="{{ commander_base }}"
|
||||||
sizes="(max-width: 900px) 100vw, 320px" />
|
data-original-name="{{ commander }}"
|
||||||
</a>
|
data-role="{{ commander_role_label or 'Commander' }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 %}>
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
|
||||||
|
width="320"
|
||||||
|
data-card-name="{{ commander_base }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label or 'Commander' }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 %}
|
||||||
|
loading="lazy" decoding="async" data-lqip="1"
|
||||||
|
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=large 672w"
|
||||||
|
sizes="(max-width: 900px) 100vw, 320px" />
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">
|
||||||
|
Commander: <span data-card-name="{{ commander }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label or 'Commander' }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if status and status.startswith('Build complete') %}
|
{% if status and status.startswith('Build complete') %}
|
||||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
{% if csv_path %}
|
{% if csv_path %}
|
||||||
|
|
@ -30,8 +57,21 @@
|
||||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
|
|
||||||
|
|
||||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
<p>Commander:
|
||||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
{% if commander %}
|
||||||
|
<strong class="commander-hover"
|
||||||
|
data-card-name="{{ commander }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label or 'Commander' }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<strong>None selected</strong>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p>Tags: {{ deck_theme_tags|default([])|join(', ') }}</p>
|
||||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||||
<div style="display:flex;align-items:center;gap:1rem;">
|
<div style="display:flex;align-items:center;gap:1rem;">
|
||||||
|
|
@ -231,15 +271,13 @@
|
||||||
{% for c in g.list %}
|
{% for c in g.list %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}"
|
||||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
|
||||||
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
|
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
|
||||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
|
||||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||||
sizes="160px" />
|
sizes="160px" />
|
||||||
</button>
|
</div>
|
||||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||||
|
|
@ -268,15 +306,13 @@
|
||||||
{% for c in added_cards %}
|
{% for c in added_cards %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}"
|
||||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
|
||||||
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
|
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
|
||||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
|
||||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||||
sizes="160px" />
|
sizes="160px" />
|
||||||
</button>
|
</div>
|
||||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||||
|
|
@ -299,7 +335,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Click a card to lock or unlock it. Locked cards are kept across reruns and won’t be replaced unless you unlock them.</div>
|
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
|
||||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||||
No cards match your filters.
|
No cards match your filters.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -324,35 +360,33 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
// Sync tile class and image-button toggle after lock button swaps
|
// Sync tile class after lock button swaps
|
||||||
document.addEventListener('htmx:afterSwap', function(ev){
|
document.addEventListener('htmx:afterSwap', function(ev){
|
||||||
try{
|
try{
|
||||||
const tgt = ev.target;
|
const tgt = ev.target;
|
||||||
if(!tgt) return;
|
if(!tgt) return;
|
||||||
// Only act for lock-box updates
|
|
||||||
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
||||||
const tile = tgt.closest('.card-tile');
|
const tile = tgt.closest('.card-tile');
|
||||||
if(!tile) return;
|
if(!tile) return;
|
||||||
const lockBtn = tgt.querySelector('.btn-lock');
|
const lockBtn = tgt.querySelector('.btn-lock');
|
||||||
if(lockBtn){
|
if(lockBtn){
|
||||||
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
||||||
tile.classList.toggle('locked', isLocked);
|
tile.classList.toggle('locked', isLocked);
|
||||||
const imgBtn = tile.querySelector('.img-btn');
|
|
||||||
if(imgBtn){
|
|
||||||
try{
|
|
||||||
const valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
|
||||||
const cur = JSON.parse(valsAttr.replace(/"/g, '"'));
|
|
||||||
const next = isLocked ? '0' : '1';
|
|
||||||
// Keep name stable; fallback to tile data attribute
|
|
||||||
const nm = cur.name || tile.getAttribute('data-card-name') || '';
|
|
||||||
imgBtn.setAttribute('hx-vals', JSON.stringify({ name: nm, locked: next }));
|
|
||||||
imgBtn.title = 'Click to ' + (isLocked ? 'unlock' : 'lock') + ' this card';
|
|
||||||
try { imgBtn.setAttribute('aria-pressed', isLocked ? 'true' : 'false'); } catch(_){ }
|
|
||||||
}catch(_){/* noop */}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}catch(_){/* noop */}
|
}catch(_){/* noop */}
|
||||||
});
|
});
|
||||||
|
// Keyboard activation for preview tile when focused
|
||||||
|
document.addEventListener('keydown', function(ev){
|
||||||
|
try{
|
||||||
|
if(ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||||
|
const target = ev.target;
|
||||||
|
if(!target || !target.classList || !target.classList.contains('img-btn')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const tile = target.closest('.card-tile');
|
||||||
|
if(tile && window.__hoverShowCard){ window.__hoverShowCard(tile); }
|
||||||
|
}catch(_){/* noop */}
|
||||||
|
});
|
||||||
// Allow dismissing/auto-clearing the last-action chip
|
// Allow dismissing/auto-clearing the last-action chip
|
||||||
document.addEventListener('click', function(ev){
|
document.addEventListener('click', function(ev){
|
||||||
try{
|
try{
|
||||||
|
|
@ -365,7 +399,6 @@ document.addEventListener('click', function(ev){
|
||||||
}catch(_){/* noop */}
|
}catch(_){/* noop */}
|
||||||
});
|
});
|
||||||
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
||||||
|
|
||||||
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
||||||
document.addEventListener('keydown', function(e){
|
document.addEventListener('keydown', function(e){
|
||||||
try{
|
try{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,17 @@
|
||||||
{% if display_name %}
|
{% if display_name %}
|
||||||
<div><strong>{{ display_name }}</strong></div>
|
<div><strong>{{ display_name }}</strong></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
<div class="muted">Commander:
|
||||||
|
<strong class="commander-hover"
|
||||||
|
data-card-name="{{ commander }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 }}</strong>
|
||||||
|
{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}
|
||||||
|
</div>
|
||||||
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||||
|
|
||||||
<div class="two-col two-col-left-rail" style="margin-top:.75rem;">
|
<div class="two-col two-col-left-rail" style="margin-top:.75rem;">
|
||||||
|
|
@ -13,10 +23,34 @@
|
||||||
{% if commander %}
|
{% if commander %}
|
||||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
<div class="commander-card"
|
||||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" width="320" />
|
tabindex="0"
|
||||||
</a>
|
style="display:inline-block; cursor:pointer;"
|
||||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
data-card-name="{{ commander_base }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 %}>
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal"
|
||||||
|
alt="{{ commander }} card image"
|
||||||
|
data-card-name="{{ commander_base }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 %}
|
||||||
|
width="320" />
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}"
|
||||||
|
data-original-name="{{ commander }}"
|
||||||
|
data-role="{{ commander_role_label }}"
|
||||||
|
{% if commander_combined_tags %}data-tags="{{ commander_combined_tags|join(', ') }}"{% endif %}
|
||||||
|
{% 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 }}</span></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
{% if csv_path %}
|
{% if csv_path %}
|
||||||
|
|
|
||||||
|
|
@ -70,20 +70,20 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if names and names|length %}
|
{% if names and names|length %}
|
||||||
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize %}data-virtualize="1"{% endif %}>
|
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize and count > 800 %}data-virtualize="1"{% endif %}>
|
||||||
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
|
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
|
||||||
{% for n in names %}
|
{% for n in names %}
|
||||||
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
||||||
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
|
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
|
||||||
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
|
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
|
||||||
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
|
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
|
||||||
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
|
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
|
||||||
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
<label class="owned-row" style="cursor:pointer;" tabindex="0" data-card-name="{{ n }}" data-original-name="{{ n }}">
|
||||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||||
<div class="owned-vstack">
|
<div class="owned-vstack">
|
||||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
||||||
sizes="100px" />
|
sizes="160px" />
|
||||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||||
{% if cols and cols|length %}
|
{% if cols and cols|length %}
|
||||||
<div class="mana-group" aria-hidden="true">
|
<div class="mana-group" aria-hidden="true">
|
||||||
|
|
@ -386,9 +386,9 @@
|
||||||
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
|
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
|
||||||
/* Owned item layout */
|
/* Owned item layout */
|
||||||
#owned-grid{ justify-items:center; }
|
#owned-grid{ justify-items:center; }
|
||||||
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:200px; margin:0 auto; }
|
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:220px; margin:0 auto; }
|
||||||
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
|
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
|
||||||
.card-thumb{ display:block; width:100px; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
|
.card-thumb{ display:block; width:160px; max-width:100%; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
|
||||||
/* Highlight only the thumbnail when selected */
|
/* Highlight only the thumbnail when selected */
|
||||||
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
|
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
|
||||||
.mana-group{ display:flex; gap:4px; justify-content:center; }
|
.mana-group{ display:flex; gap:4px; justify-content:center; }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="display:none" hx-on:load="(function(){try{var mode=localStorage.getItem('summaryTypeView')||'list';if(mode==='thumbs'){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(list&&thumbs){list.classList.add('hidden');thumbs.classList.remove('hidden');var lb=document.querySelector('.seg-btn[data-view=list]');var tb=document.querySelector('.seg-btn[data-view=thumbs]');if(lb&&tb){lb.setAttribute('aria-selected','false');tb.setAttribute('aria-selected','true');}thumbs.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid');if(!grid)return;var cs=getComputedStyle(sw);var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160;var gap=10;var width=sw.clientWidth;if(!width||width<cardW){sw.style.setProperty('--cols','1');return;}var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap)));sw.style.setProperty('--cols',String(cols));});}}catch(e){}})()"></div>
|
<div style="display:none" hx-on:load="(function(){try{var mode=localStorage.getItem('summaryTypeView')||'list';if(mode==='thumbs'){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(list&&thumbs){list.classList.add('hidden');thumbs.classList.remove('hidden');var lb=document.querySelector('.seg-btn[data-view=list]');var tb=document.querySelector('.seg-btn[data-view=thumbs]');if(lb&&tb){lb.setAttribute('aria-selected','false');tb.setAttribute('aria-selected','true');}thumbs.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid');if(!grid)return;var cs=getComputedStyle(sw);var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160;var gap=10;var width=sw.clientWidth;if(!width||width<cardW){sw.style.setProperty('--cols','1');return;}var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap)));sw.style.setProperty('--cols',String(cols));});}}catch(e){}})()"></div>
|
||||||
{% set tb = summary.type_breakdown %}
|
{% set tb = summary.type_breakdown %}
|
||||||
|
{% set synergies_norm = [] %}
|
||||||
|
{% if synergies %}
|
||||||
|
{% set synergies_norm = synergies|map('trim')|map('lower')|list %}
|
||||||
|
{% endif %}
|
||||||
{% if tb and tb.counts %}
|
{% if tb and tb.counts %}
|
||||||
<style>
|
<style>
|
||||||
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||||
|
|
@ -39,20 +43,33 @@
|
||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||||
}
|
}
|
||||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:center; align-items:center; }
|
||||||
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
||||||
</style>
|
</style>
|
||||||
<div class="list-grid">
|
<div class="list-grid">
|
||||||
{% for c in clist %}
|
{% for c in clist %}
|
||||||
<div class="list-row {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
{# Compute overlaps with detected deck synergies when available #}
|
||||||
|
{% set overlaps = [] %}
|
||||||
|
{% if synergies_norm and c.tags %}
|
||||||
|
{% for tg in c.tags %}
|
||||||
|
{% set tag_trim = tg|trim %}
|
||||||
|
{% if tag_trim and (tag_trim|lower) in synergies_norm and tag_trim not in overlaps %}
|
||||||
|
{% set _ = overlaps.append(tag_trim) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="list-row {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}"
|
||||||
|
data-card-name="{{ c.name }}" data-original-name="{{ c.name }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>
|
||||||
{% set cnt = c.count if c.count else 1 %}
|
{% set cnt = c.count if c.count else 1 %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
<span class="count">{{ cnt }}</span>
|
<span class="count">{{ cnt }}</span>
|
||||||
<span class="times">x</span>
|
<span class="times">x</span>
|
||||||
<span class="name" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">{{ c.name }}</span>
|
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
||||||
|
<span class="flip-slot" aria-hidden="true"></span>
|
||||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -74,8 +91,17 @@
|
||||||
{% for c in clist %}
|
{% for c in clist %}
|
||||||
{% set cnt = c.count if c.count else 1 %}
|
{% set cnt = c.count if c.count else 1 %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
|
{% set overlaps = [] %}
|
||||||
|
{% if synergies_norm and c.tags %}
|
||||||
|
{% for tg in c.tags %}
|
||||||
|
{% set tag_trim = tg|trim %}
|
||||||
|
{% if tag_trim and (tag_trim|lower) in synergies_norm and tag_trim not in overlaps %}
|
||||||
|
{% set _ = overlaps.append(tag_trim) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||||
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}"
|
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
||||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||||
sizes="(max-width: 1200px) 160px, 240px" />
|
sizes="(max-width: 1200px) 160px, 240px" />
|
||||||
<div class="count-badge">{{ cnt }}x</div>
|
<div class="count-badge">{{ cnt }}x</div>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
|
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if fallback_reason %}
|
{% if fallback_reason and has_primary %}
|
||||||
{% if synergy_fallback and (not resolved_list) %}
|
{% if synergy_fallback and (not resolved_list) %}
|
||||||
{% set notice_class = 'danger' %}
|
{% set notice_class = 'danger' %}
|
||||||
{% elif synergy_fallback %}
|
{% elif synergy_fallback %}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@
|
||||||
"Proliferate"
|
"Proliferate"
|
||||||
],
|
],
|
||||||
"primary_color": "Black",
|
"primary_color": "Black",
|
||||||
|
"example_commanders": [
|
||||||
|
"Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Counters Matter)",
|
||||||
|
"Rishkar, Peema Renegade - Synergy (Counters Matter)",
|
||||||
|
"Krenko, Tin Street Kingpin - Synergy (Counters Matter)",
|
||||||
|
"Yawgmoth, Thran Physician - Synergy (Proliferate)",
|
||||||
|
"Tekuthal, Inquiry Dominus - Synergy (Proliferate)"
|
||||||
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Spirit Shackle",
|
"Spirit Shackle",
|
||||||
"Greater Werewolf"
|
"Greater Werewolf"
|
||||||
|
|
@ -549,7 +556,8 @@
|
||||||
"Appa, Steadfast Guardian",
|
"Appa, Steadfast Guardian",
|
||||||
"Aang, Airbending Master",
|
"Aang, Airbending Master",
|
||||||
"Avatar Aang // Aang, Master of Elements",
|
"Avatar Aang // Aang, Master of Elements",
|
||||||
"Aang, the Last Airbender"
|
"Aang, the Last Airbender",
|
||||||
|
"Muldrotha, the Gravetide - Synergy (Avatar Kindred)"
|
||||||
],
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Appa, Steadfast Guardian",
|
"Appa, Steadfast Guardian",
|
||||||
|
|
@ -558,6 +566,12 @@
|
||||||
"Avatar Aang // Aang, Master of Elements",
|
"Avatar Aang // Aang, Master of Elements",
|
||||||
"Aang, the Last Airbender"
|
"Aang, the Last Airbender"
|
||||||
],
|
],
|
||||||
|
"synergy_commanders": [
|
||||||
|
"Multani, Yavimaya's Avatar - Synergy (Avatar Kindred)",
|
||||||
|
"Gishath, Sun's Avatar - Synergy (Avatar Kindred)",
|
||||||
|
"Niv-Mizzet, Parun - Synergy (Flying)",
|
||||||
|
"Old Gnawbone - Synergy (Flying)"
|
||||||
|
],
|
||||||
"popularity_bucket": "Rare",
|
"popularity_bucket": "Rare",
|
||||||
"editorial_quality": "draft",
|
"editorial_quality": "draft",
|
||||||
"description": "Builds around the Airbending theme and its supporting synergies."
|
"description": "Builds around the Airbending theme and its supporting synergies."
|
||||||
|
|
@ -804,7 +818,10 @@
|
||||||
"secondary_color": "Red",
|
"secondary_color": "Red",
|
||||||
"example_commanders": [
|
"example_commanders": [
|
||||||
"Kozilek, Butcher of Truth",
|
"Kozilek, Butcher of Truth",
|
||||||
"Ulamog, the Infinite Gyre"
|
"Ulamog, the Infinite Gyre",
|
||||||
|
"Ulamog, the Ceaseless Hunger - Synergy (Eldrazi Kindred)",
|
||||||
|
"Azusa, Lost but Seeking - Synergy (Ramp)",
|
||||||
|
"Birgi, God of Storytelling // Harnfel, Horn of Bounty - Synergy (Ramp)"
|
||||||
],
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Artisan of Kozilek",
|
"Artisan of Kozilek",
|
||||||
|
|
@ -816,6 +833,9 @@
|
||||||
"Ulamog's Crusher",
|
"Ulamog's Crusher",
|
||||||
"Nulldrifter"
|
"Nulldrifter"
|
||||||
],
|
],
|
||||||
|
"synergy_commanders": [
|
||||||
|
"Syr Konrad, the Grim - Synergy (Big Mana)"
|
||||||
|
],
|
||||||
"popularity_bucket": "Rare",
|
"popularity_bucket": "Rare",
|
||||||
"editorial_quality": "draft",
|
"editorial_quality": "draft",
|
||||||
"description": "Builds around the Annihilator theme and its supporting synergies."
|
"description": "Builds around the Annihilator theme and its supporting synergies."
|
||||||
|
|
@ -1015,6 +1035,13 @@
|
||||||
],
|
],
|
||||||
"primary_color": "Green",
|
"primary_color": "Green",
|
||||||
"secondary_color": "White",
|
"secondary_color": "White",
|
||||||
|
"example_commanders": [
|
||||||
|
"Azusa, Lost but Seeking - Synergy (Toughness Matters)",
|
||||||
|
"Sheoldred, the Apocalypse - Synergy (Toughness Matters)",
|
||||||
|
"Vito, Thorn of the Dusk Rose - Synergy (Toughness Matters)",
|
||||||
|
"Ragavan, Nimble Pilferer - Synergy (Little Fellas)",
|
||||||
|
"Toski, Bearer of Secrets - Synergy (Little Fellas)"
|
||||||
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Spinewoods Armadillo",
|
"Spinewoods Armadillo",
|
||||||
"Armored Armadillo"
|
"Armored Armadillo"
|
||||||
|
|
@ -1262,6 +1289,13 @@
|
||||||
"Artifact Tokens"
|
"Artifact Tokens"
|
||||||
],
|
],
|
||||||
"primary_color": "White",
|
"primary_color": "White",
|
||||||
|
"example_commanders": [
|
||||||
|
"Ragavan, Nimble Pilferer - Synergy (Artifacts Matter)",
|
||||||
|
"Loran of the Third Path - Synergy (Artifacts Matter)",
|
||||||
|
"Lotho, Corrupt Shirriff - Synergy (Artifacts Matter)",
|
||||||
|
"Urza, Lord High Artificer - Synergy (Construct Kindred)",
|
||||||
|
"Jan Jansen, Chaos Crafter - Synergy (Construct Kindred)"
|
||||||
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Academy Manufactor",
|
"Academy Manufactor",
|
||||||
"Mishra's Factory",
|
"Mishra's Factory",
|
||||||
|
|
@ -1272,6 +1306,9 @@
|
||||||
"Dutiful Replicator",
|
"Dutiful Replicator",
|
||||||
"Cogwork Assembler"
|
"Cogwork Assembler"
|
||||||
],
|
],
|
||||||
|
"synergy_commanders": [
|
||||||
|
"Peregrin Took - Synergy (Artifact Tokens)"
|
||||||
|
],
|
||||||
"popularity_bucket": "Rare",
|
"popularity_bucket": "Rare",
|
||||||
"editorial_quality": "draft",
|
"editorial_quality": "draft",
|
||||||
"description": "Focuses on getting a high number of Assembly-Worker creatures into play with shared payoffs."
|
"description": "Focuses on getting a high number of Assembly-Worker creatures into play with shared payoffs."
|
||||||
|
|
@ -1434,6 +1471,12 @@
|
||||||
"Aggro"
|
"Aggro"
|
||||||
],
|
],
|
||||||
"primary_color": "Green",
|
"primary_color": "Green",
|
||||||
|
"example_commanders": [
|
||||||
|
"Ghalta, Primal Hunger - Synergy (Trample)",
|
||||||
|
"Etali, Primal Conqueror // Etali, Primal Sickness - Synergy (Trample)",
|
||||||
|
"Ghalta, Stampede Tyrant - Synergy (Trample)",
|
||||||
|
"Etali, Primal Storm - Synergy (Aggro)"
|
||||||
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Rimehorn Aurochs",
|
"Rimehorn Aurochs",
|
||||||
"Bull Aurochs",
|
"Bull Aurochs",
|
||||||
|
|
@ -1530,7 +1573,10 @@
|
||||||
"secondary_color": "Red",
|
"secondary_color": "Red",
|
||||||
"example_commanders": [
|
"example_commanders": [
|
||||||
"Virtus the Veiled",
|
"Virtus the Veiled",
|
||||||
"Kels, Fight Fixer"
|
"Kels, Fight Fixer",
|
||||||
|
"Ragavan, Nimble Pilferer - Synergy (Outlaw Kindred)",
|
||||||
|
"Lotho, Corrupt Shirriff - Synergy (Outlaw Kindred)",
|
||||||
|
"Captain Lannery Storm - Synergy (Outlaw Kindred)"
|
||||||
],
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Mindblade Render",
|
"Mindblade Render",
|
||||||
|
|
@ -1542,6 +1588,11 @@
|
||||||
"Kels, Fight Fixer",
|
"Kels, Fight Fixer",
|
||||||
"Blaring Captain"
|
"Blaring Captain"
|
||||||
],
|
],
|
||||||
|
"synergy_commanders": [
|
||||||
|
"Sakashima of a Thousand Faces - Synergy (Rogue Kindred)",
|
||||||
|
"Rankle, Master of Pranks - Synergy (Rogue Kindred)",
|
||||||
|
"Syr Konrad, the Grim - Synergy (Sacrifice Matters)"
|
||||||
|
],
|
||||||
"popularity_bucket": "Rare",
|
"popularity_bucket": "Rare",
|
||||||
"editorial_quality": "draft",
|
"editorial_quality": "draft",
|
||||||
"description": "Focuses on getting a high number of Azra creatures into play with shared payoffs."
|
"description": "Focuses on getting a high number of Azra creatures into play with shared payoffs."
|
||||||
|
|
@ -1633,7 +1684,10 @@
|
||||||
"secondary_color": "Red",
|
"secondary_color": "Red",
|
||||||
"example_commanders": [
|
"example_commanders": [
|
||||||
"Greensleeves, Maro-Sorcerer",
|
"Greensleeves, Maro-Sorcerer",
|
||||||
"Hugs, Grisly Guardian"
|
"Hugs, Grisly Guardian",
|
||||||
|
"Ragavan, Nimble Pilferer - Synergy (Little Fellas)",
|
||||||
|
"Azusa, Lost but Seeking - Synergy (Little Fellas)",
|
||||||
|
"Toski, Bearer of Secrets - Synergy (Little Fellas)"
|
||||||
],
|
],
|
||||||
"example_cards": [
|
"example_cards": [
|
||||||
"Greensleeves, Maro-Sorcerer",
|
"Greensleeves, Maro-Sorcerer",
|
||||||
|
|
@ -1645,6 +1699,11 @@
|
||||||
"Charging Badger",
|
"Charging Badger",
|
||||||
"Badgermole"
|
"Badgermole"
|
||||||
],
|
],
|
||||||
|
"synergy_commanders": [
|
||||||
|
"Sheoldred, the Apocalypse - Synergy (Toughness Matters)",
|
||||||
|
"Vito, Thorn of the Dusk Rose - Synergy (Toughness Matters)",
|
||||||
|
"Etali, Primal Storm - Synergy (Aggro)"
|
||||||
|
],
|
||||||
"popularity_bucket": "Rare",
|
"popularity_bucket": "Rare",
|
||||||
"editorial_quality": "draft",
|
"editorial_quality": "draft",
|
||||||
"description": "Focuses on getting a high number of Badger creatures into play with shared payoffs."
|
"description": "Focuses on getting a high number of Badger creatures into play with shared payoffs."
|
||||||
|
|
@ -27938,370 +27997,12 @@
|
||||||
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
||||||
"metadata_info": {
|
"metadata_info": {
|
||||||
"mode": "merge",
|
"mode": "merge",
|
||||||
"generated_at": "2025-09-27T15:09:05",
|
"generated_at": "2025-09-30T00:38:22",
|
||||||
"curated_yaml_files": 735,
|
"curated_yaml_files": 735,
|
||||||
"synergy_cap": 5,
|
"synergy_cap": 5,
|
||||||
"inference": "pmi",
|
"inference": "pmi",
|
||||||
"version": "phase-b-merge-v1",
|
"version": "phase-b-merge-v1",
|
||||||
"catalog_hash": "f905534d554835f6fbcb2a14643f8db00f55ce4f9d40631435f6cdb12d4b2ff4"
|
"catalog_hash": "58d00ba9900f1036f00f8e831713ce53c1df5fde36899a71e9305e65e67d8f16"
|
||||||
},
|
},
|
||||||
"description_fallback_summary": {
|
"description_fallback_summary": null
|
||||||
"total_themes": 735,
|
|
||||||
"generic_total": 279,
|
|
||||||
"generic_with_synergies": 262,
|
|
||||||
"generic_plain": 17,
|
|
||||||
"generic_pct": 37.96,
|
|
||||||
"top_generic_by_frequency": [
|
|
||||||
{
|
|
||||||
"theme": "Little Fellas",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 7147,
|
|
||||||
"description": "Builds around Little Fellas leveraging synergies with Banding and Licid Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Combat Matters",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 6391,
|
|
||||||
"description": "Builds around Combat Matters leveraging synergies with Aggro and Voltron."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Interaction",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 4160,
|
|
||||||
"description": "Builds around Interaction leveraging synergies with Removal and Combat Tricks."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Toughness Matters",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 3511,
|
|
||||||
"description": "Builds around Toughness Matters leveraging synergies with Defender and Egg Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Leave the Battlefield",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 3113,
|
|
||||||
"description": "Builds around Leave the Battlefield leveraging synergies with Blink and Enter the Battlefield."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Enter the Battlefield",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 3109,
|
|
||||||
"description": "Builds around Enter the Battlefield leveraging synergies with Blink and Reanimate."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Card Draw",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 17,
|
|
||||||
"total_frequency": 2708,
|
|
||||||
"description": "Builds around Card Draw leveraging synergies with Loot and Wheels."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Life Matters",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 2423,
|
|
||||||
"description": "Builds around Life Matters leveraging synergies with Lifegain and Lifedrain."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Flying",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 2232,
|
|
||||||
"description": "Builds around Flying leveraging synergies with Phoenix Kindred and Archon Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Removal",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 1601,
|
|
||||||
"description": "Builds around Removal leveraging synergies with Soulshift and Interaction."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Legends Matter",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 1563,
|
|
||||||
"description": "Builds around Legends Matter leveraging synergies with Historics Matter and Superfriends."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Topdeck",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 1112,
|
|
||||||
"description": "Builds around Topdeck leveraging synergies with Scry and Surveil."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Discard Matters",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 1055,
|
|
||||||
"description": "Builds around Discard Matters leveraging synergies with Loot and Wheels."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Unconditional Draw",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 1050,
|
|
||||||
"description": "Builds around Unconditional Draw leveraging synergies with Dredge and Learn."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Combat Tricks",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 858,
|
|
||||||
"description": "Builds around Combat Tricks leveraging synergies with Flash and Strive."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Protection",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 810,
|
|
||||||
"description": "Builds around Protection leveraging synergies with Ward and Hexproof."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Exile Matters",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 718,
|
|
||||||
"description": "Builds around Exile Matters leveraging synergies with Impulse and Suspend."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Board Wipes",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 649,
|
|
||||||
"description": "Builds around Board Wipes leveraging synergies with Bracket:MassLandDenial and Pingers."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Pingers",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 643,
|
|
||||||
"description": "Builds around Pingers leveraging synergies with Extort and Devil Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Loot",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 526,
|
|
||||||
"description": "Builds around Loot leveraging synergies with Card Draw and Discard Matters."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Cantrips",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 515,
|
|
||||||
"description": "Builds around Cantrips leveraging synergies with Clue Token and Investigate."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "X Spells",
|
|
||||||
"popularity_bucket": "Very Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 506,
|
|
||||||
"description": "Builds around X Spells leveraging synergies with Ravenous and Firebending."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Conditional Draw",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 458,
|
|
||||||
"description": "Builds around Conditional Draw leveraging synergies with Max speed and Start your engines!."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Toolbox",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 453,
|
|
||||||
"description": "Builds around Toolbox leveraging synergies with Entwine and Bracket:TutorNonland."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Cost Reduction",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 433,
|
|
||||||
"description": "Builds around Cost Reduction leveraging synergies with Affinity and Freerunning."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Flash",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 427,
|
|
||||||
"description": "Builds around Flash leveraging synergies with Evoke and Combat Tricks."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Haste",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 402,
|
|
||||||
"description": "Builds around Haste leveraging synergies with Hellion Kindred and Phoenix Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Lifelink",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 401,
|
|
||||||
"description": "Builds around Lifelink leveraging synergies with Lifegain Triggers and Lifegain."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Vigilance",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 401,
|
|
||||||
"description": "Builds around Vigilance leveraging synergies with Angel Kindred and Mount Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Counterspells",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 397,
|
|
||||||
"description": "Builds around Counterspells leveraging synergies with Control and Stax."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Transform",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 366,
|
|
||||||
"description": "Builds around Transform leveraging synergies with Incubator Token and Incubate."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Super Friends",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 344,
|
|
||||||
"description": "Builds around Super Friends leveraging synergies with Planeswalkers and Superfriends."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Mana Dork",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 340,
|
|
||||||
"description": "Builds around Mana Dork leveraging synergies with Firebending and Scion Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Cycling",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 299,
|
|
||||||
"description": "Builds around Cycling leveraging synergies with Landcycling and Basic landcycling."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Bracket:TutorNonland",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 297,
|
|
||||||
"description": "Builds around Bracket:TutorNonland leveraging synergies with Transmute and Bracket:GameChanger."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Scry",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 284,
|
|
||||||
"description": "Builds around Scry leveraging synergies with Topdeck and Role token."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Clones",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 283,
|
|
||||||
"description": "Builds around Clones leveraging synergies with Myriad and Populate."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Reach",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 275,
|
|
||||||
"description": "Builds around Reach leveraging synergies with Spider Kindred and Archer Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "First strike",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 252,
|
|
||||||
"description": "Builds around First strike leveraging synergies with Banding and Kithkin Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Politics",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 246,
|
|
||||||
"description": "Builds around Politics leveraging synergies with Encore and Melee."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Defender",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 230,
|
|
||||||
"description": "Builds around Defender leveraging synergies with Wall Kindred and Egg Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Menace",
|
|
||||||
"popularity_bucket": "Common",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 226,
|
|
||||||
"description": "Builds around Menace leveraging synergies with Warlock Kindred and Blood Token."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Deathtouch",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 192,
|
|
||||||
"description": "Builds around Deathtouch leveraging synergies with Basilisk Kindred and Scorpion Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Equip",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 187,
|
|
||||||
"description": "Builds around Equip leveraging synergies with Job select and For Mirrodin!."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Land Types Matter",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 185,
|
|
||||||
"description": "Builds around Land Types Matter leveraging synergies with Plainscycling and Mountaincycling."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Spell Copy",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 184,
|
|
||||||
"description": "Builds around Spell Copy leveraging synergies with Storm and Replicate."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Landwalk",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 170,
|
|
||||||
"description": "Builds around Landwalk leveraging synergies with Swampwalk and Islandwalk."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Impulse",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 163,
|
|
||||||
"description": "Builds around Impulse leveraging synergies with Junk Tokens and Junk Token."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Morph",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 140,
|
|
||||||
"description": "Builds around Morph leveraging synergies with Beast Kindred and Illusion Kindred."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"theme": "Devoid",
|
|
||||||
"popularity_bucket": "Uncommon",
|
|
||||||
"synergy_count": 5,
|
|
||||||
"total_frequency": 114,
|
|
||||||
"description": "Builds around Devoid leveraging synergies with Ingest and Processor Kindred."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue