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:
matt 2025-09-29 21:32:08 -07:00
parent b0080ed482
commit a0299fbcfc
14 changed files with 1046 additions and 473 deletions

View file

@ -18,11 +18,25 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Included the tiny `csv_files/testdata` fixture set so CI fast determinism tests have consistent sample data.
### Changed
- Owned Cards library tiles now use larger thumbnails and wider columns, and virtualization only activates when more than 800 cards are present to keep scrolling smooth.
- Theme catalog schema now accepts optional `id` values on entries so refreshed catalogs validate cleanly.
- CI installs `httpx` with the rest of the web stack and runs pytest via `python -m pytest` so FastAPI tests resolve the local `code` package correctly.
- Relaxed fast-path catalog validation to allow empty synergy lists while still warning on missing or malformed data types.
- Deck summary list view now includes inline flip controls for double-faced cards, keeping text mode feature parity with thumbnail mode.
- Hover panel theme chips now highlight only the themes that triggered a cards inclusion while the full theme list displays as a muted footer without legacy bracket formatting.
- Finished deck summaries now surface overlap chips using sanitized saved metadata with a themed fallback so exported decks match the live builder UI, and hover overlap pills adopt larger, higher-contrast styling on desktop and mobile.
- Builder card tiles now reserve the card art tap/click for previewing; locking is handled exclusively by the dedicated 🔒 button so mobile users can open the hover panel without accidentally changing locks.
- Builder hover tags now surface normalized theme labels (e.g., “Card Advantage”) and suppress internal `creature_add • tag:` prefixes so build-stage pills match the final deck experience.
- Builder Step 5 commander preview now reuses the in-app hover panel (removing the external Scryfall link) and the hover reasons list auto-expands without an embedded scrollbar for easier reading on desktop and mobile.
- Finished deck commander preview now mirrors builder hover behavior with deck-selected overlap chips, the full commander theme list, and suppresses the external Scryfall link so tapping the thumbnail consistently opens the in-app panel across desktop and mobile.
### Fixed
- Hover card role badge is hidden when no role metadata is available, eliminating the empty pill shown in owned library popovers.
- Random Mode fallback warning no longer displays when all theme inputs are blank.
- Reinstated flip controls for double-faced cards in the hover preview and ensured the overlay button stays in sync with card faces.
- Hover card panel adapts for tap-to-open mobile use with centered positioning, readable scaling, and an explicit close control.
- Mobile hover layout now stacks theme chips beneath the artwork for better readability and cleans up theme formatting.
- Duplicate overlap highlighting on desktop hover has been removed; theme pills now render once without stray bullets even when multiple overlaps are present.
- Headless runner no longer loops on the power bracket prompt when owned card files exist; scripted responses now auto-select defaults with optional `HEADLESS_USE_OWNED_ONLY` / `HEADLESS_OWNED_SELECTION` overrides for automation flows.
- Regenerated `logs/perf/theme_preview_warm_baseline.json` to repair preview performance CI regressions caused by a malformed baseline file and verified the regression gate passes with the refreshed data.

View file

@ -5,6 +5,13 @@
- CI updates install the missing `httpx` dependency and run pytest through `python -m` to ensure the web stack tests import the local package correctly.
- Fast-path catalog validation now tolerates empty synergy lists while still flagging missing fields or non-string entries.
- Committed deterministic CSV fixtures under `csv_files/testdata` so CI random-mode checks have a stable dataset.
- Owned Cards library tiles are larger, virtualization only kicks in for very large libraries, and popovers no longer show empty role pills.
- Random Mode fallback warning remains hidden when no theme filters are provided, keeping Surprise Me runs noise-free.
- Hover previews regained the double-faced card flip button with state synced to the main tile, highlight only the themes that triggered inclusion, and present a mobile-friendly tap layout with centered positioning plus a close control.
- Deck summary text view exposes inline flip toggles for double-faced cards so counts and face switching stay in sync.
- Finished deck summaries now reuse sanitized metadata (with a fallback to deck meta) to display overlap chips that mirror the builder view, and hover overlap pills feature larger, higher-contrast styling for readability.
- Builder Step 5 commander preview now leverages the in-app hover panel (dropping the external Scryfall link) and the hover reasons list expands naturally without a cramped scrollbar.
- Finished deck commander preview now mirrors the builder hover chips, showing deck-selected theme overlaps alongside the full commander theme list while keeping thumbnail taps inside the app instead of bouncing to Scryfall.
- Delivered multi-theme random builds with deterministic cascade, strict match support, and polished HTMX/UI flows.
- Added opt-in telemetry counters, reroll throttling safeguards, and structured diagnostics exports.
- Expanded tooling, documentation, and QA coverage for theme governance, performance profiling, and seed history management.
@ -21,6 +28,17 @@
- Reroll throttle enforcement with banner/countdown messaging plus override hooks for attempts and timeout controls.
- Expanded fast tests validating telemetry counters, throttle behavior, and reroll permutations.
### UI polish
- Owned Cards library grid uses 160px thumbnails, wider columns, and defers virtualization until large collections to ensure smooth scrolling.
- Hover card popovers hide the role badge when a card has no role metadata, preventing blank pills in owned library previews.
- Hover card previews now include a flip control for double-faced cards, stay synchronized with the tile button, highlight the active inclusion themes, and present a centered, readable mobile layout when tapped.
- Finished deck exports render the same overlap chips as the live builder, backed by metadata fallback logic, and the hover overlap pills are larger with improved contrast.
- Finished deck commander popovers reuse deck metadata to display overlap chips and commander theme lists, and the commander thumbnail no longer opens Scryfall so tap-to-preview is consistent on mobile.
- Builder stage card tiles now keep the card art tap/click dedicated to previewing while the lock state is controlled solely by the 🔒 button, preventing accidental locks on touch devices.
- Builder hover previews now use normalized theme labels (e.g., “Card Advantage”), suppress internal prefixes so build-mode pills match the finished deck summary, and the Step 5 commander preview stays in-app with hover reasons expanding without scrollbars.
- Deck summary text rows gained inline flip buttons beside card counts to mirror thumbnail behavior for double-faced cards.
- Random Mode fallback banner is suppressed when all theme inputs are empty so Surprise Me runs stay uncluttered.
### Tooling & docs
- Random theme exclusion catalog with reporting script and documentation, alongside a multi-theme performance profiler and regression guard.
- Taxonomy snapshot tooling, splash penalty analytics, and governance documentation updated for strict alias and example enforcement.

View file

@ -5,11 +5,11 @@ from fastapi.responses import HTMLResponse
from pathlib import Path
import csv
import os
from typing import Dict, List, Tuple, Optional
from typing import Any, Dict, List, Optional, Tuple
from ..app import templates
# from ..services import owned_store
from ..services.summary_utils import summary_ctx
from ..services.orchestrator import tags_for_commander
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
router = APIRouter(prefix="/decks")
@ -264,6 +264,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
summary = None
commander_name = ''
tags: List[str] = []
meta_info: Dict[str, Any] = {}
sidecar = p.with_suffix('.summary.json')
if sidecar.exists():
try:
@ -273,6 +274,7 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
summary = payload.get('summary')
meta = payload.get('meta', {})
if isinstance(meta, dict):
meta_info = meta
commander_name = meta.get('commander') or ''
_tags = meta.get('tags') or []
if isinstance(_tags, list):
@ -302,7 +304,97 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"tags": tags,
"display_name": display_name,
}
ctx.update(summary_ctx(summary=summary, commander=commander_name, tags=tags))
ctx.update(summary_ctx(summary=summary, commander=commander_name, tags=tags, meta=meta_info))
def _extend_sources(values: list[Any], candidate: Any) -> None:
if isinstance(candidate, list):
values.extend(candidate)
elif isinstance(candidate, tuple):
values.extend(list(candidate))
elif isinstance(candidate, str):
values.append(candidate)
deck_theme_sources: list[Any] = list(ctx.get("synergies") or tags or [])
if isinstance(meta_info, dict):
for key in (
"display_themes",
"resolved_themes",
"auto_filled_themes",
"random_display_themes",
"random_resolved_themes",
"random_auto_filled_themes",
"primary_theme",
"secondary_theme",
"tertiary_theme",
):
_extend_sources(deck_theme_sources, meta_info.get(key))
deck_theme_tags = format_theme_list(deck_theme_sources)
commander_theme_sources: list[Any] = []
if isinstance(meta_info, dict):
for key in (
"commander_tags",
"commander_theme_tags",
"commander_themes",
"commander_tag_list",
"primary_commander_theme",
"secondary_commander_theme",
):
_extend_sources(commander_theme_sources, meta_info.get(key))
commander_meta = meta_info.get("commander", {})
if isinstance(commander_meta, dict):
_extend_sources(commander_theme_sources, commander_meta.get("tags"))
_extend_sources(commander_theme_sources, commander_meta.get("themes"))
commander_theme_tags = format_theme_list(commander_theme_sources)
if not commander_theme_tags and commander_name:
commander_theme_tags = format_theme_list(tags_for_commander(commander_name))
combined_tags: list[str] = []
combined_seen: set[str] = set()
for collection in (commander_theme_tags, deck_theme_tags):
for label in collection:
key = label.casefold()
if key in combined_seen:
continue
combined_seen.add(key)
combined_tags.append(label)
overlap_tags: list[str] = []
overlap_seen: set[str] = set()
combined_keys = {label.casefold() for label in combined_tags}
for label in deck_theme_tags:
key = label.casefold()
if key in combined_keys and key not in overlap_seen:
overlap_tags.append(label)
overlap_seen.add(key)
commander_tag_slugs = []
slug_seen: set[str] = set()
for label in combined_tags:
slug = " ".join(str(label or "").strip().lower().split())
if not slug or slug in slug_seen:
continue
slug_seen.add(slug)
commander_tag_slugs.append(slug)
reason_bits: list[str] = []
if deck_theme_tags:
reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags))
if commander_theme_tags:
reason_bits.append("Commander tags: " + ", ".join(commander_theme_tags))
commander_reason_text = "; ".join(reason_bits)
ctx.update(
{
"deck_theme_tags": deck_theme_tags,
"commander_theme_tags": commander_theme_tags,
"commander_combined_tags": combined_tags,
"commander_tag_slugs": commander_tag_slugs,
"commander_reason_text": commander_reason_text,
"commander_overlap_tags": overlap_tags,
"commander_role_label": format_theme_label("Commander"),
}
)
return templates.TemplateResponse("decks/view.html", ctx)

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Dict, Iterable, Optional
from fastapi import Request
from ..services import owned_store
from . import orchestrator as orch
@ -91,6 +91,141 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
return ctx
def _extend_sources(target: list[Any], values: Any) -> None:
if not values:
return
if isinstance(values, (list, tuple, set)):
for item in values:
if item is None:
continue
target.append(item)
else:
target.append(values)
def commander_hover_context(
commander_name: str | None,
deck_tags: Iterable[Any] | None,
summary: Dict[str, Any] | None,
) -> Dict[str, Any]:
try:
from .summary_utils import format_theme_label, format_theme_list
except Exception:
# Fallbacks in the unlikely event of circular import issues
def format_theme_label(value: Any) -> str: # type: ignore[redef]
text = str(value or "").strip().replace("_", " ")
if not text:
return ""
parts = []
for chunk in text.split():
if chunk.isupper():
parts.append(chunk)
else:
parts.append(chunk[:1].upper() + chunk[1:].lower())
return " ".join(parts)
def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef]
seen: set[str] = set()
result: list[str] = []
for raw in values or []: # type: ignore[arg-type]
label = format_theme_label(raw)
if not label or len(label) <= 1:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
result.append(label)
return result
deck_theme_sources: list[Any] = []
_extend_sources(deck_theme_sources, list(deck_tags or []))
meta_info: Dict[str, Any] = {}
if isinstance(summary, dict):
meta_info = summary.get("meta") or {}
if isinstance(meta_info, dict):
for key in (
"display_themes",
"resolved_themes",
"auto_filled_themes",
"random_display_themes",
"random_resolved_themes",
"random_auto_filled_themes",
"primary_theme",
"secondary_theme",
"tertiary_theme",
):
_extend_sources(deck_theme_sources, meta_info.get(key))
deck_theme_tags = format_theme_list(deck_theme_sources)
commander_theme_sources: list[Any] = []
if isinstance(meta_info, dict):
for key in (
"commander_tags",
"commander_theme_tags",
"commander_themes",
"commander_tag_list",
"primary_commander_theme",
"secondary_commander_theme",
):
_extend_sources(commander_theme_sources, meta_info.get(key))
commander_meta = meta_info.get("commander") if isinstance(meta_info, dict) else {}
if isinstance(commander_meta, dict):
_extend_sources(commander_theme_sources, commander_meta.get("tags"))
_extend_sources(commander_theme_sources, commander_meta.get("themes"))
commander_theme_tags = format_theme_list(commander_theme_sources)
if commander_name and not commander_theme_tags:
try:
commander_theme_tags = format_theme_list(orch.tags_for_commander(commander_name))
except Exception:
commander_theme_tags = []
combined_tags: list[str] = []
combined_seen: set[str] = set()
for source in (commander_theme_tags, deck_theme_tags):
for label in source:
key = label.casefold()
if key in combined_seen:
continue
combined_seen.add(key)
combined_tags.append(label)
overlap_tags: list[str] = []
overlap_seen: set[str] = set()
combined_keys = {label.casefold() for label in combined_tags}
for label in deck_theme_tags:
key = label.casefold()
if key in combined_keys and key not in overlap_seen:
overlap_tags.append(label)
overlap_seen.add(key)
commander_tag_slugs: list[str] = []
slug_seen: set[str] = set()
for label in combined_tags:
slug = " ".join(str(label or "").strip().lower().split())
if not slug or slug in slug_seen:
continue
slug_seen.add(slug)
commander_tag_slugs.append(slug)
reason_bits: list[str] = []
if deck_theme_tags:
reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags))
if commander_theme_tags:
reason_bits.append("Commander tags: " + ", ".join(commander_theme_tags))
return {
"deck_theme_tags": deck_theme_tags,
"commander_theme_tags": commander_theme_tags,
"commander_combined_tags": combined_tags,
"commander_tag_slugs": commander_tag_slugs,
"commander_overlap_tags": overlap_tags,
"commander_reason_text": "; ".join(reason_bits),
"commander_role_label": format_theme_label("Commander") if commander_name else "",
}
def step5_ctx_from_result(
request: Request,
sess: dict,
@ -132,6 +267,13 @@ def step5_ctx_from_result(
}
if extras:
ctx.update(extras)
hover_meta = commander_hover_context(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=ctx.get("summary") if ctx.get("summary") else res.get("summary"),
)
ctx.update(hover_meta)
return ctx

View file

@ -13,6 +13,137 @@ import re
import unicodedata
from glob import glob
_TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"}
_REASON_SOURCE_OVERRIDES = {
"creature_all_theme": "Theme Match",
"creature_add": "Creature Package",
"creature_fill": "Creature Fill",
"creature_phase": "Creature Stage",
"creatures": "Creature Stage",
"lands": "Lands",
"land_phase": "Land Stage",
"spells": "Spells",
"autocombos": "Combo Package",
"enforcement": "Enforcement",
"lock": "Lock",
}
def _humanize_tag_label(tag: Any) -> str:
"""Return a human-friendly display label for a tag identifier."""
try:
raw = str(tag).strip()
except Exception:
raw = ""
if not raw:
return ""
# Replace common separators with spaces and collapse whitespace
cleaned = raw.replace("", " ")
cleaned = re.sub(r"[_\-]+", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
cleaned = re.sub(r"\s*:\s*", ": ", cleaned)
if not cleaned:
return ""
words = cleaned.split(" ")
friendly_parts: List[str] = []
for word in words:
if not word:
continue
upper_word = word.upper()
if upper_word in _TAG_ACRONYM_KEEP or (len(word) <= 3 and word.isupper()):
friendly_parts.append(upper_word)
continue
if word.isupper() or word.islower():
friendly_parts.append(word.capitalize())
continue
friendly_parts.append(word[0].upper() + word[1:])
return " ".join(friendly_parts)
def _humanize_reason_source(value: Any) -> str:
try:
raw = str(value).strip()
except Exception:
raw = ""
if not raw:
return ""
key = raw.lower()
if key in _REASON_SOURCE_OVERRIDES:
return _REASON_SOURCE_OVERRIDES[key]
# Split camelCase before normalizing underscores
split_camel = re.sub(r"(?<!^)([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 ---
def _run_theme_metadata_enrichment(out_func=None) -> None:
"""Run full metadata enrichment sequence after theme catalog/YAML generation.
@ -2443,14 +2574,38 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
trig = str(entry.get('TriggerTag') or '').strip()
parts: list[str] = []
if role:
parts.append(role)
parts.append(_humanize_tag_label(role))
if sub_role:
parts.append(sub_role)
if added_by:
parts.append(f"by {added_by}")
parts.append(_humanize_tag_label(sub_role))
friendly_added = _humanize_reason_source(added_by)
if friendly_added:
parts.append(friendly_added)
friendly_trig = _humanize_tag_label(trig)
if trig:
parts.append(f"tag: {trig}")
reason = "".join(parts)
tag_fragment = friendly_trig or str(trig).strip()
if tag_fragment:
parts.append(f"tag: {tag_fragment}")
deduped_parts: list[str] = []
seen_parts: set[str] = set()
for part in parts:
if not part:
continue
norm = part.strip().lower()
if not norm or norm in seen_parts:
continue
seen_parts.add(norm)
deduped_parts.append(part)
reason = "".join(deduped_parts)
display_tags = _display_tags_from_entry(entry)
slug_tags: List[str] = []
slug_seen: set[str] = set()
for source_list in (_coerce_tag_iterable(entry.get('Tags')), _split_composite_tags(trig)):
for tag_val in source_list:
key_slug = str(tag_val).strip().lower()
if not key_slug or key_slug in slug_seen:
continue
slug_seen.add(key_slug)
slug_tags.append(str(tag_val).strip())
added_cards.append({
"name": name,
"count": delta_count,
@ -2458,6 +2613,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"role": role,
"sub_role": sub_role,
"trigger_tag": trig,
"tags": display_tags,
"tags_slug": slug_tags,
})
except Exception:
continue

View file

@ -1,31 +1,158 @@
from __future__ import annotations
from typing import Any, Dict
from typing import Any, Dict, Iterable, List, Optional
from deck_builder import builder_constants as bc
from .build_utils import owned_set as owned_set_helper
from .combo_utils import detect_for_summary as _detect_for_summary
def _sanitize_tag_list(values: Iterable[Any]) -> List[str]:
cleaned: List[str] = []
for raw in values or []: # type: ignore[arg-type]
text = str(raw or "").strip()
if not text:
continue
if text.startswith("['"):
text = text[2:]
if text.endswith("']") and len(text) >= 2:
text = text[:-2]
if text.startswith('"') and text.endswith('"') and len(text) >= 2:
text = text[1:-1]
if text.startswith("'") and text.endswith("'") and len(text) >= 2:
text = text[1:-1]
text = text.strip(" []\t\n\r")
if not text:
continue
cleaned.append(text)
return cleaned
def _normalize_summary_tags(summary: dict[str, Any] | None) -> None:
if not summary:
return
try:
type_breakdown = summary.get("type_breakdown") or {}
cards_by_type = type_breakdown.get("cards") or {}
for clist in cards_by_type.values():
if not isinstance(clist, list):
continue
for card in clist:
if not isinstance(card, dict):
continue
tags = card.get("tags") or []
if tags:
card["tags"] = _sanitize_tag_list(tags)
except Exception:
pass
def format_theme_label(raw: Any) -> str:
text = str(raw or "").strip()
if not text:
return ""
text = text.replace("_", " ")
words = []
for part in text.split():
if not part:
continue
if part.isupper():
words.append(part)
else:
words.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
return " ".join(words)
def format_theme_list(values: Iterable[Any]) -> List[str]:
seen: set[str] = set()
result: List[str] = []
for raw in values or []: # type: ignore[arg-type]
label = format_theme_label(raw)
if not label:
continue
if len(label) <= 1:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
result.append(label)
return result
def summary_ctx(
*,
summary: dict | None,
commander: str | None = None,
tags: list[str] | None = None,
meta: Optional[dict[str, Any]] = None,
include_versions: bool = True,
) -> Dict[str, Any]:
"""Build a unified context payload for deck summary panels.
Provides owned_set, game_changers, combos/synergies, and detector versions.
"""
_normalize_summary_tags(summary)
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
combos = det.get("combos", [])
synergies = det.get("synergies", [])
synergies_raw = det.get("synergies", []) or []
# Flatten synergy tag names while preserving appearance order and collapsing duplicates case-insensitively
synergy_tags: list[str] = []
seen: set[str] = set()
for entry in synergies_raw:
if entry is None:
continue
if isinstance(entry, dict):
tags = entry.get("tags", []) or []
else:
tags = getattr(entry, "tags", None) or []
for tag in tags:
text = str(tag).strip()
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
synergy_tags.append(text)
if not synergy_tags:
fallback_sources: list[str] = []
for collection in (tags or []):
fallback_sources.append(str(collection))
meta_obj = meta or {}
meta_keys = [
"display_themes",
"resolved_themes",
"auto_filled_themes",
"random_display_themes",
"random_resolved_themes",
"random_auto_filled_themes",
"primary_theme",
"secondary_theme",
"tertiary_theme",
]
for key in meta_keys:
value = meta_obj.get(key) if isinstance(meta_obj, dict) else None
if isinstance(value, list):
fallback_sources.extend(str(v) for v in value)
elif isinstance(value, str):
fallback_sources.append(value)
for raw in fallback_sources:
label = format_theme_label(raw)
if not label:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
synergy_tags.append(label)
versions = det.get("versions", {} if include_versions else None)
return {
"owned_set": owned_set_helper(),
"game_changers": bc.GAME_CHANGERS,
"combos": combos,
"synergies": synergies,
"synergies": synergy_tags,
"synergy_pairs": synergies_raw,
"versions": versions,
"commander": commander,
"tags": tags or [],

View file

@ -286,9 +286,10 @@
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
var role = (tile.getAttribute('data-role')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase();
var owned = tile.getAttribute('data-owned') === '1';
var text = name + ' ' + role + ' ' + tags;
var text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug;
var qOk = !query || text.indexOf(query) !== -1;
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
var show = qOk && oOk;

View file

@ -154,7 +154,7 @@
.card-hover{ display: none !important; }
}
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
.card-hover .ov-chip { display:inline-block; background:#0ea5e91a; color:#0ea5e9; border:1px solid #0ea5e9; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; }
.card-hover .ov-chip { display:inline-block; background:#38bdf8; color:#102746; border:1px solid #0f3a57; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; font-weight:600; }
/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */
.card-hover .dual.two-faced img { width:320px; }
.card-hover .dual.two-faced { gap:8px; }
@ -178,7 +178,7 @@
#hover-card-panel .hcp-taglist li.overlap { font-weight:600; color:var(--accent,#38bdf8); }
#hover-card-panel .hcp-taglist li.overlap::before { content:'•'; color:var(--accent,#38bdf8); position:absolute; left:-10px; }
#hover-card-panel .hcp-overlaps { font-size:10px; line-height:1.25; margin-top:2px; }
#hover-card-panel .hcp-ov-chip { display:inline-block; background:var(--accent,#38bdf8); color:#fff; border:1px solid var(--accent,#38bdf8); border-radius:10px; padding:2px 5px; font-size:9px; margin-right:4px; margin-top:2px; }
#hover-card-panel .hcp-ov-chip { display:inline-flex; align-items:center; background:var(--accent,#38bdf8); color:#102746; border:1px solid rgba(10,54,82,.6); border-radius:9999px; padding:3px 10px; font-size:13px; margin-right:6px; margin-top:4px; font-weight:500; letter-spacing:.02em; }
/* Hide modal-specific close button outside modal host */
#preview-close-btn { display:none; }
#theme-preview-modal #preview-close-btn { display:inline-flex; }
@ -191,6 +191,28 @@
.dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); }
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
.dfc-toggle[aria-pressed='true'] { box-shadow:0 0 0 2px var(--accent, #38bdf8); }
.list-row .dfc-toggle { position:static; width:auto; height:auto; border-radius:6px; padding:2px 8px; font-size:12px; opacity:1; backdrop-filter:none; margin-left:4px; }
.list-row .dfc-toggle .icon { font-size:12px; }
.list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); }
.list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); }
#hover-card-panel.mobile { left:50% !important; top:auto !important; bottom:max(16px, 5vh); transform:translateX(-50%); width:min(92vw, 420px) !important; max-height:80vh; overflow-y:auto; padding:16px 18px; pointer-events:auto !important; }
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:18px; }
#hover-card-panel.mobile .hcp-img { max-width:100%; margin:0 auto; }
#hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; }
#hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
#hover-card-panel.mobile .hcp-meta { font-size:13px; text-align:left; }
#hover-card-panel.mobile .hcp-overlaps { display:flex; flex-wrap:wrap; gap:6px; width:100%; }
#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { margin:0; }
#hover-card-panel.mobile .hcp-taglist { columns:1; display:flex; flex-wrap:wrap; gap:6px; margin:4px 0 2px; max-height:none; overflow:visible; padding:0; }
#hover-card-panel.mobile .hcp-taglist li { background:rgba(37,99,235,.18); border-radius:9999px; padding:4px 10px; display:inline-flex; align-items:center; }
#hover-card-panel.mobile .hcp-taglist li.overlap { background:rgba(37,99,235,.28); color:#dbeafe; }
#hover-card-panel.mobile .hcp-taglist li.overlap::before { display:none; }
#hover-card-panel.mobile .hcp-reasons { max-height:220px; width:100%; }
#hover-card-panel.mobile .hcp-tags { word-break:normal; white-space:normal; text-align:left; width:100%; font-size:12px; opacity:.7; }
#hover-card-panel .hcp-close { appearance:none; border:none; background:transparent; color:#9ca3af; font-size:18px; line-height:1; padding:2px 4px; cursor:pointer; border-radius:6px; display:none; }
#hover-card-panel .hcp-close:focus { outline:2px solid rgba(59,130,246,.6); outline-offset:2px; }
#hover-card-panel.mobile .hcp-close { display:inline-flex; }
/* Fade transition for hover panel image */
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
@ -462,13 +484,14 @@
.filter(function(t){ return t && t.trim(); });
var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; });
var overlapSet = new Set(overlaps);
var highlightOverlapsInList = overlaps.length === 0;
if (role || (tags && tags.length)) {
var html = '';
if (role) {
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'&lt;') + '</div>';
}
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,'&lt;'); 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,'&lt;'); var isOverlap = overlapSet.has(t); return '<li' + ((highlightOverlapsInList && isOverlap) ? ' class="overlap"' : '') + '>' + safe + '</li>'; }).join('') + '</ul></div>';
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,'&lt;')+'</span>'; }).join(' ') + '</div>';
}
@ -530,13 +553,27 @@
var LS_PREFIX = 'mtg:face:';
var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches
var lastFlip = 0;
var normalize = (window.__normalizeCardName) ? window.__normalizeCardName : function(raw){
if(!raw) return raw;
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
if(m){ return m[1].trim(); }
return raw;
};
window.__normalizeCardName = normalize;
function getCardData(card, attr){
if(!card) return '';
var val = card.getAttribute(attr);
if(val) return val;
var node = card.querySelector && card.querySelector('['+attr+']');
return node ? node.getAttribute(attr) : '';
}
function hasTwoFaces(card){
if(!card) return false;
var name = normalizeCardName((card.getAttribute('data-card-name')||'')) + ' ' + normalizeCardName((card.getAttribute('data-original-name')||''));
var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name'));
return name.indexOf('//') > -1;
}
function keyFor(card){
var nm = normalizeCardName(card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase();
var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, 'data-original-name') || '').toLowerCase();
return LS_PREFIX + nm;
}
function applyStoredFace(card){
@ -556,7 +593,7 @@
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
document.body.appendChild(live);
}
var nm = normalizeCardName(card.getAttribute('data-card-name')||'').split('//')[0].trim();
var nm = normalize(getCardData(card, 'data-card-name')||'').split('//')[0].trim();
live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm;
}
function updateButton(btn, face){
@ -564,10 +601,15 @@
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
}
window.__dfcUpdateButton = updateButton;
function ensureButton(card){
if(!hasTwoFaces(card)) return;
if(card.querySelector('.dfc-toggle')) return;
card.classList.add('dfc-host');
var resolvedName = getCardData(card, 'data-card-name');
var resolvedOriginal = getCardData(card, 'data-original-name');
if(resolvedName && !card.hasAttribute('data-card-name')) card.setAttribute('data-card-name', resolvedName);
if(resolvedOriginal && !card.hasAttribute('data-original-name')) card.setAttribute('data-original-name', resolvedOriginal);
applyStoredFace(card);
var face = card.getAttribute(FACE_ATTR) || 'front';
var btn = document.createElement('button');
@ -578,7 +620,22 @@
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
btn.addEventListener('keydown', function(ev){ if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){ ev.preventDefault(); flip(card, btn); }});
updateButton(btn, face);
card.insertBefore(btn, card.firstChild);
if(card.classList.contains('list-row')){
btn.classList.add('dfc-toggle-inline');
var slot = card.querySelector('.flip-slot');
if(slot){
slot.innerHTML='';
slot.appendChild(btn);
slot.removeAttribute('aria-hidden');
} else {
var anchor = card.querySelector('.dfc-anchor');
if(anchor){ anchor.insertAdjacentElement('afterend', btn); }
else if(card.lastElementChild){ card.insertBefore(btn, card.lastElementChild); }
else { card.appendChild(btn); }
}
} else {
card.insertBefore(btn, card.firstChild);
}
}
function flip(card, btn){
var now = Date.now();
@ -594,9 +651,12 @@
announce(next, card);
// retrigger hover update under pointer if applicable
if(window.__hoverShowCard){ window.__hoverShowCard(card); }
if(window.__dfcNotifyHover){ try{ window.__dfcNotifyHover(card, next); }catch(_){ } }
}
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
function scan(){
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile').forEach(ensureButton);
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
document.addEventListener('DOMContentLoaded', scan);
@ -833,6 +893,7 @@
'<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;">&nbsp;</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 class="hcp-body">'+
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
@ -845,7 +906,7 @@
'</div>'+
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
'<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>'+
'</div>';
@ -868,13 +929,54 @@
var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags');
var coarseQuery = window.matchMedia('(pointer: coarse)');
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
if(coarseQuery){
var handler = function(){ refreshPosition(); };
if(coarseQuery.addEventListener){ coarseQuery.addEventListener('change', handler); }
else if(coarseQuery.addListener){ coarseQuery.addListener(handler); }
}
window.addEventListener('resize', refreshPosition);
var closeBtn = panel.querySelector('.hcp-close');
if(closeBtn && !closeBtn.__bound){
closeBtn.__bound = true;
closeBtn.addEventListener('click', function(ev){ ev.preventDefault(); hide(); });
}
function positionPanel(evt){
if(isMobileMode()){
panel.classList.add('mobile');
var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05));
panel.style.bottom = bottomOffset + 'px';
panel.style.left = '50%';
panel.style.top = 'auto';
panel.style.right = 'auto';
panel.style.transform = 'translateX(-50%)';
panel.style.pointerEvents = 'auto';
} else {
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.transform = 'none';
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect();
if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
if(x < 8) x = 8;
if(y < 8) y = 8;
panel.style.left = x+'px'; panel.style.top = y+'px';
panel.style.bottom = 'auto';
panel.style.right = 'auto';
}
}
function move(evt){
if(panel.style.display==='none') return;
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect();
if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
panel.style.left = x+'px'; panel.style.top = y+'px';
if(!evt){ evt = window.__lastPointerEvent; }
if(!evt && lastCard){
var rect = lastCard.getBoundingClientRect();
evt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 };
}
if(!evt){ evt = { clientX: window.innerWidth/2, clientY: window.innerHeight/2 }; }
positionPanel(evt);
}
// Lightweight image prefetch LRU cache (size 12) (P2 UI Hover image prefetch)
var _imgLRU=[];
@ -893,57 +995,143 @@
var mana = (attr('data-mana')||'').trim();
var role = (attr('data-role')||'').trim();
var reasonsRaw = attr('data-reasons')||'';
var tags = attr('data-tags')||'';
var tagsRaw = attr('data-tags')||'';
var reasonsRaw = attr('data-reasons')||'';
var roleEl = panel.querySelector('.hcp-role');
var hasFlip = !!card.querySelector('.dfc-toggle');
var tagListEl = panel.querySelector('.hcp-taglist');
var overlapsEl = panel.querySelector('.hcp-overlaps');
var overlapsAttr = attr('data-overlaps') || '';
var overlapArr = overlapsAttr.split(/\s*,\s*/).filter(Boolean);
function displayLabel(text){
if(!text) return '';
var label = String(text);
label = label.replace(/[\u2022\-_]+/g, ' ');
label = label.replace(/\s+/g, ' ').trim();
return label;
}
function parseTagList(raw){
if(!raw) return [];
var trimmed = String(raw).trim();
if(!trimmed) return [];
var result = [];
var candidate = trimmed;
if(trimmed[0] === '[' && trimmed[trimmed.length-1] === ']'){
candidate = trimmed.slice(1, -1);
}
// Try JSON parsing after normalizing quotes
try {
var normalized = trimmed;
if(trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1){
normalized = trimmed.replace(/'/g, '"');
}
var parsed = JSON.parse(normalized);
if(Array.isArray(parsed)){
result = parsed;
}
} catch(_){ /* fall back below */ }
if(!result || !result.length){
result = candidate.split(/\s*,\s*/);
}
return result.map(function(t){ return String(t || '').trim(); }).filter(Boolean);
}
function deriveTagsFromReasons(reasons){
if(!reasons) return [];
// Reasons often include "because it overlaps X, Y" or "by <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;
rarityEl.textContent = rarity;
metaEl.textContent = [role?('Role: '+role):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • ');
var roleLabel = displayLabel(role);
var roleKey = (roleLabel || role || '').toLowerCase();
var isCommanderRole = roleKey === 'commander';
metaEl.textContent = [roleLabel?('Role: '+roleLabel):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • ');
reasonsList.innerHTML='';
reasonsRaw.split(';').map(function(r){return r.trim();}).filter(Boolean).forEach(function(r){ var li=document.createElement('li'); li.style.margin='2px 0'; li.textContent=r; reasonsList.appendChild(li); });
// Build inline tag list with overlap highlighting
if(tagListEl){
tagListEl.innerHTML='';
if(tags){
var tagArr = tags.split(/\s*,\s*/).filter(Boolean);
var setOverlap = new Set(overlapArr);
tagArr.forEach(function(t){
var li = document.createElement('li');
if(setOverlap.has(t)) li.className='overlap';
li.textContent = t;
tagListEl.appendChild(li);
});
}
tagListEl.style.display = 'none';
tagListEl.setAttribute('aria-hidden','true');
}
if(overlapsEl){
overlapsEl.innerHTML = overlapArr.map(function(o){ return '<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-commander', isCommanderRole);
var fuzzy = encodeURIComponent(nm);
var rawName = nm || '';
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
if(hasBack) hasFlip = true;
var storageKey = 'mtg:face:' + rawName.toLowerCase();
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
var chosenFace = card.getAttribute('data-current-face') || 'front';
(function(){
lastCard = card;
function renderHoverFace(face){
var desiredVersion='large';
var faceParam = (chosenFace==='back') ? '&face=back' : '';
var currentKey = nm+':'+chosenFace+':'+desiredVersion;
var faceParam = (face==='back') ? '&face=back' : '';
var currentKey = nm+':'+face+':'+desiredVersion;
var prevFace = imgEl.getAttribute('data-face');
var faceChanged = prevFace && prevFace !== chosenFace;
var faceChanged = prevFace && prevFace !== face;
if(imgEl.getAttribute('data-current')!== currentKey){
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
if(faceChanged){ imgEl.style.opacity = 0; }
prefetch(src);
imgEl.src = src;
imgEl.setAttribute('data-current', currentKey);
imgEl.setAttribute('data-face', chosenFace);
imgEl.setAttribute('data-face', face);
imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); });
}
if(!imgEl.__errBound){
@ -954,10 +1142,33 @@
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
});
}
})();
panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt); lastCard = card;
}
renderHoverFace(chosenFace);
window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null;
if(evt){ window.__lastPointerEvent = evt; }
if(isMobileMode()){
panel.classList.add('mobile');
panel.style.pointerEvents = 'auto';
panel.style.maxHeight = '80vh';
} else {
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.maxHeight = '';
panel.style.bottom = 'auto';
}
panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt);
}
function hide(){
panel.style.display='none';
panel.setAttribute('aria-hidden','true');
cancelSchedule();
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.transform = 'none';
panel.style.bottom = 'auto';
panel.style.maxHeight = '';
window.__dfcNotifyHover = null;
}
function hide(){ panel.style.display='none'; panel.setAttribute('aria-hidden','true'); cancelSchedule(); }
document.addEventListener('mousemove', move);
function getCardFromEl(el){
if(!el) return null;
@ -978,6 +1189,7 @@
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
document.addEventListener('pointerover', function(e){
if(isMobileMode()) return;
var card = getCardFromEl(e.target);
if(!card) return;
// If hovering flip button, refresh immediately (no activation delay)
@ -989,6 +1201,7 @@
schedule(card, e);
});
document.addEventListener('pointerout', function(e){
if(isMobileMode()) return;
var relCard = getCardFromEl(e.relatedTarget);
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
if(!panel.contains(e.relatedTarget)){
@ -996,6 +1209,21 @@
if(!relCard) hide();
}
});
document.addEventListener('click', function(e){
if(!isMobileMode()) return;
if(panel.contains(e.target)) return;
if(e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return;
if(e.target.closest && e.target.closest('button, input, select, textarea, a')) return;
var card = getCardFromEl(e.target);
if(card){
cancelSchedule();
var rect = card.getBoundingClientRect();
var syntheticEvt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 };
show(card, syntheticEvt);
} else if(panel.style.display==='block'){
hide();
}
});
// Expose show function for external refresh (flip updates)
window.__hoverShowCard = function(card){
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };

View file

@ -3,12 +3,39 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview">
{# 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) %}
<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"
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" />
</a>
<div class="commander-card" tabindex="0"
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 %}>
<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') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if csv_path %}
@ -30,8 +57,21 @@
<div hx-get="/build/banner" hx-trigger="load"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
<p>Commander:
{% 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;">
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
<div style="display:flex;align-items:center;gap:1rem;">
@ -231,15 +271,13 @@
{% for c in g.list %}
{% 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)) %}
<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' }}">
<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' }}"
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
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(/&quot;/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){}})()">
<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-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 %}>
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<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"
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="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;">
@ -268,15 +306,13 @@
{% for c in added_cards %}
{% 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)) %}
<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' }}">
<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' }}"
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
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(/&quot;/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){}})()">
<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-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 %}>
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<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"
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="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;">
@ -299,7 +335,7 @@
{% endfor %}
</div>
{% 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 wont 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;">
No cards match your filters.
</div>
@ -324,35 +360,33 @@
</div>
</section>
<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){
try{
const tgt = ev.target;
if(!tgt) return;
// Only act for lock-box updates
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
const tile = tgt.closest('.card-tile');
if(!tile) return;
const lockBtn = tgt.querySelector('.btn-lock');
if(lockBtn){
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
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(/&quot;/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 */}
}
tile.classList.toggle('locked', isLocked);
}
}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
document.addEventListener('click', function(ev){
try{
@ -365,7 +399,6 @@ document.addEventListener('click', function(ev){
}catch(_){/* noop */}
});
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
document.addEventListener('keydown', function(e){
try{

View file

@ -5,7 +5,17 @@
{% if display_name %}
<div><strong>{{ display_name }}</strong></div>
{% 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="two-col two-col-left-rail" style="margin-top:.75rem;">
@ -13,10 +23,34 @@
{% if commander %}
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
{% 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">
<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" />
</a>
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
<div class="commander-card"
tabindex="0"
style="display:inline-block; cursor:pointer;"
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 %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if csv_path %}

View file

@ -70,20 +70,20 @@
{% endif %}
{% 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 %}>
<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;">
<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(240px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
{% for n in names %}
{% 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 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) %}
<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 }}" />
<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 %}
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" />
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="160px" />
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% if cols and cols|length %}
<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 item layout */
#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; }
.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 */
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; }

View file

@ -11,6 +11,10 @@
</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 synergies_norm = [] %}
{% if synergies %}
{% set synergies_norm = synergies|map('trim')|map('lower')|list %}
{% endif %}
{% if tb and tb.counts %}
<style>
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
@ -39,20 +43,33 @@
@media (max-width: 1199px) {
.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 .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 .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; }
</style>
<div class="list-grid">
{% 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 owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<span class="count">{{ cnt }}</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>
</div>
{% endfor %}
@ -74,8 +91,17 @@
{% for c in clist %}
{% 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 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 %}">
<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"
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</div>

View file

@ -69,7 +69,7 @@
Auto-filled: <strong>{{ auto_filled_themes|join(', ') }}</strong>
</div>
{% endif %}
{% if fallback_reason %}
{% if fallback_reason and has_primary %}
{% if synergy_fallback and (not resolved_list) %}
{% set notice_class = 'danger' %}
{% elif synergy_fallback %}

View file

@ -72,6 +72,13 @@
"Proliferate"
],
"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": [
"Spirit Shackle",
"Greater Werewolf"
@ -549,7 +556,8 @@
"Appa, Steadfast Guardian",
"Aang, Airbending Master",
"Avatar Aang // Aang, Master of Elements",
"Aang, the Last Airbender"
"Aang, the Last Airbender",
"Muldrotha, the Gravetide - Synergy (Avatar Kindred)"
],
"example_cards": [
"Appa, Steadfast Guardian",
@ -558,6 +566,12 @@
"Avatar Aang // Aang, Master of Elements",
"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",
"editorial_quality": "draft",
"description": "Builds around the Airbending theme and its supporting synergies."
@ -804,7 +818,10 @@
"secondary_color": "Red",
"example_commanders": [
"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": [
"Artisan of Kozilek",
@ -816,6 +833,9 @@
"Ulamog's Crusher",
"Nulldrifter"
],
"synergy_commanders": [
"Syr Konrad, the Grim - Synergy (Big Mana)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
"description": "Builds around the Annihilator theme and its supporting synergies."
@ -1015,6 +1035,13 @@
],
"primary_color": "Green",
"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": [
"Spinewoods Armadillo",
"Armored Armadillo"
@ -1262,6 +1289,13 @@
"Artifact Tokens"
],
"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": [
"Academy Manufactor",
"Mishra's Factory",
@ -1272,6 +1306,9 @@
"Dutiful Replicator",
"Cogwork Assembler"
],
"synergy_commanders": [
"Peregrin Took - Synergy (Artifact Tokens)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
"description": "Focuses on getting a high number of Assembly-Worker creatures into play with shared payoffs."
@ -1434,6 +1471,12 @@
"Aggro"
],
"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": [
"Rimehorn Aurochs",
"Bull Aurochs",
@ -1530,7 +1573,10 @@
"secondary_color": "Red",
"example_commanders": [
"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": [
"Mindblade Render",
@ -1542,6 +1588,11 @@
"Kels, Fight Fixer",
"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",
"editorial_quality": "draft",
"description": "Focuses on getting a high number of Azra creatures into play with shared payoffs."
@ -1633,7 +1684,10 @@
"secondary_color": "Red",
"example_commanders": [
"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": [
"Greensleeves, Maro-Sorcerer",
@ -1645,6 +1699,11 @@
"Charging Badger",
"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",
"editorial_quality": "draft",
"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)",
"metadata_info": {
"mode": "merge",
"generated_at": "2025-09-27T15:09:05",
"generated_at": "2025-09-30T00:38:22",
"curated_yaml_files": 735,
"synergy_cap": 5,
"inference": "pmi",
"version": "phase-b-merge-v1",
"catalog_hash": "f905534d554835f6fbcb2a14643f8db00f55ce4f9d40631435f6cdb12d4b2ff4"
"catalog_hash": "58d00ba9900f1036f00f8e831713ce53c1df5fde36899a71e9305e65e67d8f16"
},
"description_fallback_summary": {
"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."
}
]
}
"description_fallback_summary": null
}