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

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