mtg_python_deckbuilder/code/web/templates/base.html

1299 lines
66 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en" data-theme="{% if default_theme == 'light' %}light-blend{% elif default_theme == 'dark' %}dark{% else %}light-blend{% endif %}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<script>
// Ensure legacy hover system never initializes (set before its script executes)
window.__disableLegacyCardHover = true;
</script>
<script>
(function(){
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
try{
var root = document.documentElement;
var KEY = 'mtg:theme';
var SERVER_DEFAULT = '{{ default_theme }}';
var params = new URLSearchParams(window.location.search || '');
var urlTheme = (params.get('theme') || '').toLowerCase();
var stored = localStorage.getItem(KEY);
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function mapTheme(v){
var x = (v || 'system').toLowerCase();
if (x === 'system') return resolveSystem();
if (x === 'light') return 'light-blend';
return x;
}
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
root.setAttribute('data-theme', mapTheme(initial));
}catch(_){ }
})();
</script>
<script>
window.__telemetryEndpoint = '/telemetry/events';
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
<link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" />
<style>
/* Disable all transitions until page is loaded to prevent sidebar flash */
.no-transition,
.no-transition *,
.no-transition *::before,
.no-transition *::after {
transition: none !important;
animation: none !important;
}
</style>
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
{% if enable_pwa %}
<link rel="manifest" href="/static/manifest.webmanifest" />
{% endif %}
</head>
<body class="no-transition" data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
<header class="top-banner">
<div class="top-inner">
<div class="flex-row banner-left">
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border); flex-shrink: 0;">
☰ Menu
</button>
<h1 style="margin:0; white-space: nowrap;">MTG Deckbuilder</h1>
</div>
</div>
</header>
<div class="layout">
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
<div class="brand">
<div class="mana-dots" aria-hidden="true">
<span class="dot green"></span>
<span class="dot blue"></span>
<span class="dot red"></span>
<span class="dot white"></span>
<span class="dot black"></span>
</div>
</div>
<nav class="nav" id="primary-nav">
<a href="/">Home</a>
<a href="/build">Build</a>
<a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
<a href="/cards">All Cards</a>
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
<a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a>
{% if random_ui %}<a href="/random">Random</a>{% endif %}
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
{% if enable_themes %}
<div class="sidebar-theme" role="group" aria-label="Theme">
<label class="sidebar-theme-label" for="theme-select">Theme</label>
<div class="sidebar-theme-row">
<select id="theme-select" aria-label="Theme selector">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="high-contrast">High contrast</option>
<option value="cb-friendly">Color-blind</option>
</select>
<button type="button" id="theme-reset" class="btn btn-ghost" title="Reset theme preference">Reset</button>
</div>
</div>
{% endif %}
</aside>
<main class="content" data-error-surface>
{% block content %}{% endblock %}
</main>
</div>
<footer class="site-footer" role="contentinfo">
Card images and data provided by
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
</footer>
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
<style>
.nav a.active { font-weight:600; position:relative; }
.nav a.active::after { content:''; position:absolute; left:0; bottom:2px; width:100%; height:2px; background:var(--accent, #38bdf8); border-radius:2px; }
</style>
<script>
(function(){
// Sidebar toggle and persistence
try{
var BODY = document.body;
var SIDEBAR = document.getElementById('sidebar');
var TOGGLE = document.getElementById('nav-toggle');
var KEY = 'mtg:navCollapsed';
function apply(collapsed){
if (collapsed){
BODY.classList.add('nav-collapsed');
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'false');
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'true');
} else {
BODY.classList.remove('nav-collapsed');
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'true');
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'false');
}
}
// Initial state: respect saved pref, else collapse on small screens
var saved = localStorage.getItem(KEY);
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
apply(initialCollapsed);
// Re-enable transitions after page is fully loaded
// Use longer delay for pages with heavy content (like card browser)
var enableTransitions = function(){
BODY.classList.remove('no-transition');
};
if (document.readyState === 'complete') {
// Already loaded
setTimeout(enableTransitions, 150);
} else {
window.addEventListener('load', function(){
setTimeout(enableTransitions, 150);
});
}
if (TOGGLE){
TOGGLE.addEventListener('click', function(){
var isCollapsed = BODY.classList.contains('nav-collapsed');
apply(!isCollapsed);
try{ localStorage.setItem(KEY, (!isCollapsed) ? '1' : '0'); }catch(_){ }
});
}
// Keep ARIA in sync on resize for first-load default when no pref yet
window.addEventListener('resize', function(){
// Do not override if user has an explicit preference saved
if (localStorage.getItem(KEY) !== null) return;
apply((window.innerWidth || 0) < 900);
});
}catch(_){ }
// Suppress sidebar transitions during HTMX partial updates (not full page loads)
document.addEventListener('htmx:beforeRequest', function(evt){
// Only suppress for small partial updates (identified by specific IDs)
var target = evt.detail && evt.detail.target;
if (target && target.id) {
var targetId = target.id;
// List of partial update containers that should suppress sidebar transitions
var partialContainers = ['similar-cards-container', 'card-list', 'theme-list'];
if (partialContainers.indexOf(targetId) !== -1 || targetId.indexOf('-container') !== -1) {
document.body.classList.add('htmx-settling');
}
}
});
document.addEventListener('htmx:afterSettle', function(){
document.body.classList.remove('htmx-settling');
});
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){
if (!statusEl) statusEl = document.getElementById('banner-status');
return statusEl;
}
function renderSetupStatus(data){
var el = ensureStatusEl(); if (!el) return;
if (data && data.running) {
var msg = (data.message || 'Preparing data...');
var pct = (typeof data.percent === 'number') ? data.percent : null;
// Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
var suppress = false;
if (pct !== null && pct >= 99) suppress = true;
var lm = (msg || '').toLowerCase();
if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true;
if (suppress) {
if (el.innerHTML) { el.innerHTML=''; el.classList.remove('busy'); }
return;
}
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
el.classList.add('busy');
} else if (data && data.phase === 'done') {
el.innerHTML = '';
el.classList.remove('busy');
} else if (data && data.phase === 'error') {
el.innerHTML = '<span class="error">Setup error.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
} else {
if (!el.innerHTML.trim()) el.innerHTML = '';
el.classList.remove('busy');
}
}
function pollStatus(){
try {
fetch('/status/setup', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderSetupStatus)
.catch(function(){ /* noop */ });
} catch(e) {}
}
// Poll every 10 seconds instead of 3 to reduce server load (only for header indicator)
setInterval(pollStatus, 10000);
pollStatus();
function ensureCard() {
// Legacy large image hover kept for fallback; disabled in favor of unified hover-card-panel
if (window.__disableLegacyCardHover) return document.getElementById('card-hover') || document.createElement('div');
var pop = document.getElementById('card-hover');
if (!pop) {
pop = document.createElement('div');
pop.id = 'card-hover';
pop.className = 'card-hover';
var inner = document.createElement('div');
inner.className = 'card-hover-inner';
var img = document.createElement('img');
img.alt = 'Card preview';
var img2 = document.createElement('img');
img2.alt = 'Card preview'; img2.style.display = 'none';
var meta = document.createElement('div');
meta.className = 'card-meta';
var dual = document.createElement('div');
dual.className = 'dual';
dual.appendChild(img);
dual.appendChild(img2);
inner.appendChild(dual);
inner.appendChild(meta);
pop.appendChild(inner);
document.body.appendChild(pop);
}
return pop;
}
var cardPop = ensureCard();
var PREVIEW_VERSIONS = ['normal','large'];
function normalizeCardName(raw){
if(!raw) return raw;
// Strip ' - Synergy (...' annotation if present
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
if(m){ return m[1].trim(); }
return raw;
}
function buildCardUrl(name, version, nocache, face){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = '/api/images/' + (version||'normal') + '/' + q;
if (face === 'back') url += '?face=back';
if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now();
return url;
}
// Generic card image URL builder
function buildScryfallImageUrl(name, version, nocache){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = '/api/images/' + (version||'normal') + '/' + q;
if (nocache) url += '?t=' + Date.now();
return url;
}
// Global image retry binding for any <img data-card-name>
var IMG_FLAG = '__cardImgRetry';
function bindCardImageRetry(img, versions){
try {
if (!img || img[IMG_FLAG]) return;
var name = img.getAttribute('data-card-name') || '';
if (!name) return;
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
img.addEventListener('error', function(){
var st = img[IMG_FLAG];
if (!st) return;
if (st.vi < st.versions.length - 1){
st.vi += 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else if (!st.nocache){
st.nocache = 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
}
});
// If the initial load already failed before binding, try the next immediately
if (img.complete && img.naturalWidth === 0){
// If src corresponds to the first version, move to next; else, just force a reload
var st = img[IMG_FLAG];
var current = img.src || '';
var first = buildScryfallImageUrl(name, st.versions[0], false);
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
st.vi = Math.min(1, st.versions.length - 1);
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else {
// Re-trigger current request (may succeed if transient)
img.src = current;
}
}
} catch(_){}
}
function bindAllCardImageRetries(){
document.querySelectorAll('img[data-card-name]').forEach(function(img){
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
bindCardImageRetry(img, versions);
});
}
function positionCard(e) {
var x = e.clientX + 16, y = e.clientY + 16;
cardPop.style.display = 'block';
cardPop.style.left = x + 'px';
cardPop.style.top = y + 'px';
var rect = cardPop.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
}
function attachCardHover() {
if (window.__disableLegacyCardHover) return; // short-circuit legacy system
document.querySelectorAll('[data-card-name]').forEach(function(el) {
if (el.__cardHoverBound) return; // avoid duplicate bindings
el.__cardHoverBound = true;
el.addEventListener('mouseenter', function(e) {
var img = cardPop.querySelector('.card-hover-inner img');
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
if (img2) img2.style.display = 'none';
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
if (img2) { img2.style.display = 'none'; }
if (dualNode) { dualNode.classList.remove('combo','two-faced'); }
var meta = cardPop.querySelector('.card-meta');
var name = el.getAttribute('data-card-name') || '';
var vi = 0; // always start at 'normal' on hover
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front');
// Bind a one-off error handler per enter to try fallbacks
var triedNoCache = false;
function onErr(){
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front'); }
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true, 'front'); }
else { img.removeEventListener('error', onErr); }
}
img.addEventListener('error', onErr, { once:false });
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
// Attempt to load back face (double-faced / transform). If it fails, we silently hide.
if (img2) {
img2.style.display = 'none';
var backTriedNoCache = false;
var backIdx = 0;
function backErr(){
if (backIdx < PREVIEW_VERSIONS.length - 1){
backIdx += 1; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], false, 'back');
} else if (!backTriedNoCache){
backTriedNoCache = true; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], true, 'back');
} else {
img2.removeEventListener('error', backErr); img2.style.display='none';
}
}
function backOk(){ img2.removeEventListener('error', backErr); img2.removeEventListener('load', backOk); if (dualNode) dualNode.classList.add('two-faced'); img2.style.display=''; }
img2.addEventListener('error', backErr, { once:false });
img2.addEventListener('load', backOk, { once:false });
img2.src = buildCardUrl(name, PREVIEW_VERSIONS[0], false, 'back');
}
var role = el.getAttribute('data-role') || '';
var rawTags = el.getAttribute('data-tags') || '';
var overlapsRaw = el.getAttribute('data-overlaps') || '';
// Clean and split tags into an array; remove brackets and quotes
var tags = rawTags
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
.split(/\s*,\s*/)
.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;'); 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>';
}
}
meta.innerHTML = html;
meta.style.display = '';
} else {
meta.style.display = 'none';
meta.innerHTML = '';
}
positionCard(e);
});
el.addEventListener('mousemove', positionCard);
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
});
// Dual-card hover for combo rows
document.querySelectorAll('[data-combo-names]').forEach(function(el){
if (el.__comboHoverBound) return; el.__comboHoverBound = true;
el.addEventListener('mouseenter', function(e){
var namesAttr = el.getAttribute('data-combo-names') || '';
var parts = namesAttr.split('||');
var a = (parts[0]||'').trim(); var b = (parts[1]||'').trim();
if (!a || !b) return;
var img = cardPop.querySelector('.card-hover-inner img');
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
var meta = cardPop.querySelector('.card-meta');
if (img2) img2.style.display = '';
var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false;
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false, 'front');
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false, 'front');
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
if (dualNode){ dualNode.classList.add('combo'); dualNode.classList.remove('two-faced'); }
function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} }
function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} }
img.addEventListener('error', err1, { once:false });
img2.addEventListener('error', err2, { once:false });
img.addEventListener('load', function on1(){ img.removeEventListener('load', on1); img.removeEventListener('error', err1); });
img2.addEventListener('load', function on2(){ img2.removeEventListener('load', on2); img2.removeEventListener('error', err2); });
meta.style.display = 'none'; meta.innerHTML = '';
positionCard(e);
});
el.addEventListener('mousemove', positionCard);
el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; });
});
}
// Expose re-init functions globally for dynamic content
window.attachCardHover = attachCardHover;
window.bindAllCardImageRetries = bindAllCardImageRetries;
attachCardHover();
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script>
// Overlay flip button + persistence + accessibility for double-faced cards
(function(){
var FACE_ATTR = 'data-current-face';
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;
// Check if card has a layout attribute - this is the source of truth
var layout = card.getAttribute('data-layout') || '';
if(layout) {
// Only these layouts are actual flippable double-faced cards
var flippableLayouts = ['modal_dfc', 'transform', 'reversible_card', 'flip', 'meld'];
return flippableLayouts.indexOf(layout) > -1;
}
// Fallback: If no layout data, check if name has // (backwards compatibility)
// This shouldn't happen if templates properly pass data-layout
var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name'));
return name.indexOf('//') > -1;
}
window.__dfcHasTwoFaces = hasTwoFaces; // Expose globally for popup hover panel
function keyFor(card){
var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, 'data-original-name') || '').toLowerCase();
return LS_PREFIX + nm;
}
function applyStoredFace(card){
try {
var k = keyFor(card);
var val = localStorage.getItem(k);
if(val === 'front' || val === 'back') card.setAttribute(FACE_ATTR, val);
} catch(_){}
}
function storeFace(card, face){
try { localStorage.setItem(keyFor(card), face); } catch(_){}
}
function announce(face, card){
var live = document.getElementById('dfc-live');
if(!live){
live = document.createElement('div');
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
document.body.appendChild(live);
}
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){
btn.setAttribute('data-face', face);
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
}
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');
btn.type='button';
// Mobile: flip in popup only (flex below md). Desktop: flip in thumbnails only (hidden at md+)
var inPopup = card.closest && card.closest('#hover-card-panel');
btn.className = inPopup ? 'dfc-toggle flex md:hidden' : 'dfc-toggle hidden md:flex';
btn.setAttribute('aria-pressed','false');
btn.setAttribute('tabindex','0');
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);
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);
}
}
window.__dfcEnsureButton = ensureButton; // Expose for hover panel use
function flip(card, btn){
var now = Date.now();
if(now - lastFlip < DEBOUNCE_MS) return;
lastFlip = now;
var cur = card.getAttribute(FACE_ATTR) || 'front';
var next = cur === 'front' ? 'back' : 'front';
card.setAttribute(FACE_ATTR, next);
storeFace(card, next);
if(btn) updateButton(btn, next);
// visual cue
card.style.outline='2px solid var(--accent)'; setTimeout(function(){ card.style.outline=''; }, 160);
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, .commander-thumb, .commander-card, .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);
document.addEventListener('htmx:afterSwap', scan);
// Expose for debugging
window.__dfcScan = scan;
// MutationObserver to re-inject buttons if card tiles are replaced (e.g., HTMX swaps, dynamic filtering)
var moDebounce = null;
var observer = new MutationObserver(function(muts){
if(moDebounce) cancelAnimationFrame(moDebounce);
moDebounce = requestAnimationFrame(function(){ scan(); });
});
try { observer.observe(document.body, { childList:true, subtree:true }); } catch(_){ }
})();
</script>
<script>
(function(){
try {
var path = window.location.pathname || '/';
var nav = document.getElementById('primary-nav'); if(!nav) return;
var links = nav.querySelectorAll('a');
var best = null; var bestLen = -1;
links.forEach(function(a){
var href = a.getAttribute('href') || '';
if(!href) return;
// Exact match or prefix match (ignoring trailing slash)
if(path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
if(href.length > bestLen){ best = a; bestLen = href.length; }
}
});
if(best) best.classList.add('active');
} catch(_) {}
})();
</script>
<script src="/static/components.js?v=20250121-1"></script>
<script src="/static/app.js?v=20250826-4"></script>
{% if enable_themes %}
<script>
(function(){
try{
var sel = document.getElementById('theme-select');
var resetBtn = document.getElementById('theme-reset');
var root = document.documentElement;
var KEY = 'mtg:theme';
var SERVER_DEFAULT = '{{ default_theme }}';
function mapLight(v){ return v === 'light' ? 'light-blend' : v; }
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function normalizeUiValue(v){
var x = (v||'system').toLowerCase();
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
return x;
}
function apply(val){
var v = (val || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
v = mapLight(v);
root.setAttribute('data-theme', v);
}
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
var params = new URLSearchParams(window.location.search || '');
var urlTheme = (params.get('theme') || '').toLowerCase();
if (urlTheme) {
// Persist the UI value, not the mapped CSS token
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
// Clean the URL so reloads don't keep overriding
try { var u = new URL(window.location.href); u.searchParams.delete('theme'); window.history.replaceState({}, document.title, u.toString()); } catch(_){ }
}
// Determine initial selection: URL -> localStorage -> server default -> system
var stored = localStorage.getItem(KEY);
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
apply(initial);
if (sel){
sel.value = normalizeUiValue(initial);
sel.addEventListener('change', function(){
var v = sel.value || 'system';
localStorage.setItem(KEY, v);
apply(v);
});
}
if (resetBtn){
resetBtn.addEventListener('click', function(){
try{ localStorage.removeItem(KEY); }catch(_){ }
var v = SERVER_DEFAULT || 'system';
apply(v);
if (sel) sel.value = normalizeUiValue(v);
});
}
// React to system changes when set to system
if (window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){
var cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
if (cur === 'system') apply('system');
});
}
}catch(_){ }
})();
</script>
{% endif %}
{% if not enable_themes %}
<script>
(function(){
try{
// Apply THEME env even when the selector is disabled. Resolve 'system' to OS preference.
var root = document.documentElement;
var SERVER_DEFAULT = '{{ default_theme }}';
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
var v = (SERVER_DEFAULT || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
if (v === 'light') v = 'light-blend';
root.setAttribute('data-theme', v);
// Track OS changes when using system
if ((SERVER_DEFAULT||'system').toLowerCase() === 'system' && window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){ root.setAttribute('data-theme', resolveSystem()); });
}
}catch(_){ }
})();
</script>
{% endif %}
{% if enable_pwa %}
<script>
(function(){
try{
if ('serviceWorker' in navigator){
var ver = '{{ catalog_hash|default("dev") }}';
var url = '/static/sw.js?v=' + encodeURIComponent(ver);
navigator.serviceWorker.register(url).then(function(reg){
window.__pwaStatus = { registered: true, scope: reg.scope, version: ver };
// Listen for updates (new worker installing)
if(reg.waiting){ reg.waiting.postMessage({ type: 'SKIP_WAITING' }); }
reg.addEventListener('updatefound', function(){
try {
var nw = reg.installing; if(!nw) return;
nw.addEventListener('statechange', function(){
if(nw.state === 'installed' && navigator.serviceWorker.controller){
// New version available; reload silently for freshness
try { sessionStorage.setItem('mtg:swUpdated','1'); }catch(_){ }
window.location.reload();
}
});
}catch(_){ }
});
}).catch(function(){ window.__pwaStatus = { registered: false }; });
}
}catch(_){ }
})();
</script>
{% endif %}
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){
try{
var raw = sessionStorage.getItem('mtg:toastAfterReload');
if (raw){
sessionStorage.removeItem('mtg:toastAfterReload');
var data = JSON.parse(raw);
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
}
}catch(_){ }
})();
</script>
<script>
// Global delegated hover card panel initializer (ensures functionality after HTMX swaps)
(function(){
// Disable legacy multi-element hover in favor of single unified panel
window.__disableLegacyCardHover = true;
// Global delegated curated-only & reasons controls (works after HTMX swaps and inline render)
function findPreviewRoot(el){ return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); }
function applyCuratedFor(root){
var checkbox = root.querySelector('#curated-only-toggle');
var status = root.querySelector('#preview-status');
if(!checkbox) return;
// persist
try{ localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1':'0'); }catch(_){ }
var curatedOnly = checkbox.checked;
var hidden=0;
root.querySelectorAll('.card-sample').forEach(function(card){
var role = card.getAttribute('data-role');
var isCurated = role==='example'|| role==='curated_synergy' || role==='synthetic';
if(curatedOnly && !isCurated){ card.style.display='none'; hidden++; } else { card.style.display=''; }
});
if(status) status.textContent = curatedOnly ? ('Hid '+hidden+' sampled cards') : '';
}
function applyReasonsFor(root){
var cb = root.querySelector('#reasons-toggle'); if(!cb) return;
try{ localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1':'0'); }catch(_){ }
var show = cb.checked;
root.querySelectorAll('[data-reasons-block]').forEach(function(el){ el.style.display = show ? '' : 'none'; });
}
document.addEventListener('change', function(e){
if(e.target && e.target.id === 'curated-only-toggle'){
var root = findPreviewRoot(e.target); if(root) applyCuratedFor(root);
}
});
document.addEventListener('change', function(e){
if(e.target && e.target.id === 'reasons-toggle'){
var root = findPreviewRoot(e.target); if(root) applyReasonsFor(root);
}
});
document.addEventListener('htmx:afterSwap', function(ev){
var frag = ev.target;
if(frag && frag.querySelector){
if(frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag);
if(frag.querySelector('#reasons-toggle')) applyReasonsFor(frag);
}
});
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('.preview-modal-content').forEach(function(root){
// restore persisted states before applying
try {
var cVal = localStorage.getItem('mtg:preview.curatedOnly');
if(cVal !== null){ var cb = root.querySelector('#curated-only-toggle'); if(cb){ cb.checked = cVal === '1'; } }
var rVal = localStorage.getItem('mtg:preview.showReasons');
if(rVal !== null){ var rb = root.querySelector('#reasons-toggle'); if(rb){ rb.checked = rVal === '1'; } }
}catch(_){ }
if(root.querySelector('#curated-only-toggle')) applyCuratedFor(root);
if(root.querySelector('#reasons-toggle')) applyReasonsFor(root);
});
});
function createPanel(){
var panel = document.createElement('div');
panel.id = 'hover-card-panel';
panel.setAttribute('role','dialog');
panel.setAttribute('aria-label','Card detail hover panel');
panel.setAttribute('aria-hidden','true');
panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:#1f2937;border:1px solid #374151;border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:#f3f4f6;font-size:14px;line-height:1.45;pointer-events:none;';
panel.innerHTML = ''+
'<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;">'+
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />'+
'</div>'+
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">'+
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">'+
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;">&nbsp;</div>'+
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>'+
'</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;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>';
document.body.appendChild(panel);
return panel;
}
function ensurePanel(){
var panel = document.getElementById('hover-card-panel');
if (panel) return panel;
// Auto-create for direct theme pages where fragment-specific markup not injected
return createPanel();
}
function setup(){
var panel = ensurePanel();
if(!panel || panel.__hoverInit) return;
panel.__hoverInit = true;
var imgEl = panel.querySelector('.hcp-img');
var nameEl = panel.querySelector('.hcp-name');
var rarityEl = panel.querySelector('.hcp-rarity');
var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags');
var bodyEl = panel.querySelector('.hcp-body');
var rightCol = panel.querySelector('.hcp-right');
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');
panel.style.bottom = 'auto';
panel.style.left = '50%';
panel.style.top = '50%';
panel.style.right = 'auto';
panel.style.transform = 'translate(-50%, -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;
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=[];
function prefetch(src){ if(!src) return; if(_imgLRU.indexOf(src)===-1){ _imgLRU.push(src); if(_imgLRU.length>12) _imgLRU.shift(); var im=new Image(); im.src=src; } }
var activationDelay=120; // ms (P2 optional activation delay)
var hoverTimer=null;
function schedule(card, evt){ clearTimeout(hoverTimer); hoverTimer=setTimeout(function(){ show(card, evt); }, activationDelay); }
function cancelSchedule(){ clearTimeout(hoverTimer); }
var lastCard = null;
function show(card, evt){
if(!card) return;
// Prefer attributes on container, fallback to child (image) if missing
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
var simpleSource = null;
if(card.closest){
simpleSource = card.closest('[data-hover-simple]');
}
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
var rarity = (attr('data-rarity')||'').trim();
var mana = (attr('data-mana')||'').trim();
var role = (attr('data-role')||'').trim();
var reasonsRaw = attr('data-reasons')||'';
var tagsRaw = attr('data-tags')||'';
var metadataTagsRaw = attr('data-metadata-tags')||''; // M5: Extract metadata 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') || '';
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;
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='';
tagListEl.style.display = 'none';
tagListEl.setAttribute('aria-hidden','true');
}
if(overlapsEl){
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(', ');
// M5: Temporarily append metadata tags for debugging
if(metadataTagsRaw && metadataTagsRaw.trim()){
var metaTags = metadataTagsRaw.split(',').map(function(t){return t.trim();}).filter(Boolean);
if(metaTags.length){
var metaText = metaTags.map(displayLabel).join(', ');
tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText);
}
}
tagsEl.textContent = tagText;
tagsEl.style.display = tagText ? '' : 'none';
}
}
if(roleEl){
roleEl.textContent = roleLabel || '';
roleEl.style.display = roleLabel ? 'inline-block' : 'none';
}
panel.classList.toggle('is-payoff', role === 'payoff');
panel.classList.toggle('is-commander', isCommanderRole);
var hasDetails = !forceSimple && (
!!roleLabel || !!mana || !!rarity || (reasonsRaw && reasonsRaw.trim()) || (overlapArr && overlapArr.length) || (allTags && allTags.length)
);
panel.classList.toggle('hcp-simple', !hasDetails);
if(rightCol){
rightCol.style.display = hasDetails ? 'flex' : 'none';
}
if(bodyEl){
if(!hasDetails){
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.alignItems = 'center';
bodyEl.style.gap = '12px';
} else {
bodyEl.style.display = '';
bodyEl.style.flexDirection = '';
bodyEl.style.alignItems = '';
bodyEl.style.gap = '';
}
}
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';
lastCard = card;
function renderHoverFace(face){
var desiredVersion='normal'; // Use 'normal' since we only cache small/normal
var currentKey = nm+':'+face+':'+desiredVersion;
var prevFace = imgEl.getAttribute('data-face');
var faceChanged = prevFace && prevFace !== face;
if(imgEl.getAttribute('data-current')!== currentKey){
// For DFC cards, extract the specific face name for cache lookup
// but also send face parameter for Scryfall fallback
var faceName = nm;
var isDFC = nm.indexOf('//')>-1;
if(isDFC){
var faces = nm.split('//');
faceName = (face==='back') ? faces[1].trim() : faces[0].trim();
}
// Use cache-aware API endpoint with the specific face name
// Add face parameter for DFC back faces to help Scryfall fallback
var src='/api/images/'+desiredVersion+'/'+encodeURIComponent(faceName);
if(isDFC && face==='back'){
src += '?face=back';
}
if(faceChanged){ imgEl.style.opacity = 0; }
prefetch(src);
imgEl.src = src;
imgEl.setAttribute('data-current', currentKey);
imgEl.setAttribute('data-face', face);
imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); });
}
if(!imgEl.__errBound){
imgEl.__errBound = true;
imgEl.addEventListener('error', function(){
var cur = imgEl.getAttribute('src')||'';
// Fallback from normal to small if image fails to load
if(cur.indexOf('/api/images/normal/')>-1){
imgEl.src = cur.replace('/api/images/normal/','/api/images/small/');
}
});
}
}
renderHoverFace(chosenFace);
// Add DFC flip button to popup panel ONLY on mobile
var checkFlip = window.__dfcHasTwoFaces || function(){ return false; };
if(hasFlip && imgEl && checkFlip(card) && isMobileMode()){
var imgWrap = imgEl.parentElement; // .hcp-img-wrap
if(imgWrap && !imgWrap.querySelector('.dfc-toggle')){
// Create a custom flip button that flips the ORIGINAL card (lastCard)
// This ensures the popup refreshes with updated tags/themes
var flipBtn = document.createElement('button');
flipBtn.type = 'button';
flipBtn.className = 'dfc-toggle'; // No responsive classes needed - only created on mobile
flipBtn.setAttribute('aria-pressed', 'false');
flipBtn.setAttribute('tabindex', '0');
flipBtn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
// Flip the ORIGINAL card element, not the popup wrapper
flipBtn.addEventListener('click', function(ev){
ev.stopPropagation();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard); // This will trigger popup refresh
}
});
flipBtn.addEventListener('keydown', function(ev){
if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){
ev.preventDefault();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard);
}
}
});
imgWrap.classList.add('dfc-host');
imgWrap.appendChild(flipBtn);
}
}
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;
}
document.addEventListener('mousemove', move);
function getCardFromEl(el){
if(!el) return null;
if(el.closest){
var altBtn = el.closest('.alts button[data-card-name]');
if(altBtn){ return altBtn; }
}
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
// For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile)
if(el.closest && el.closest('.card-tile')){
var imgBtn = el.closest('.img-btn');
if(imgBtn) return imgBtn.closest('.card-tile');
// If directly on the image
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))){
return el.closest('.card-tile');
}
// Don't trigger on other parts of the tile (buttons, text, etc.)
return null;
}
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
if(container) return container;
// Image-based detection (any card image carrying data-card-name)
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
var up = el.closest && el.closest('.stack-card');
return up || el; // fall back to the image itself
}
// List view spans (deck summary list mode, finished deck list, etc.)
if(el.hasAttribute && el.hasAttribute('data-card-name')) return el;
return null;
}
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)
if(e.target.closest && e.target.closest('.dfc-toggle')){
show(card, e);
return;
}
if(lastCard === card && panel.style.display==='block') { return; }
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)){
cancelSchedule();
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) };
show(card, ev);
};
window.hoverShowByName = function(name){
try {
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
} catch(_) {}
};
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); if(!next) hide(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
// Compact mode event listener
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
}
document.addEventListener('htmx:afterSwap', setup);
document.addEventListener('DOMContentLoaded', setup);
setup();
})();
</script>
<script>
// Global compact mode toggle function (UI Hover compact mode toggle)
(function(){
window.toggleHoverCompactMode = function(state){
if(typeof state==='boolean') window.__hoverCompactMode = state; else window.__hoverCompactMode = !window.__hoverCompactMode;
document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle'));
};
})();
</script>
</body>
</html>