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

@ -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 %}