Finalize MDFC follow-ups, docs, and diagnostics tooling

document deck summary DFC badges, exporter annotations, and per-face metadata across README/DOCKER/release notes

record completion of all MDFC roadmap follow-ups and add the authoring guide for multi-face CSV entries

wire in optional DFC_PER_FACE_SNAPSHOT env support, exporter regression tests, and diagnostics updates noted in the changelog
This commit is contained in:
matt 2025-10-02 15:31:05 -07:00
parent 6fefda714e
commit 88cf832bf2
46 changed files with 3292 additions and 86 deletions

View file

@ -662,7 +662,7 @@
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, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .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);
@ -1206,9 +1206,9 @@
if(!el) return null;
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .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'))){
@ -1264,12 +1264,12 @@
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, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .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'); 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'); if(!next) hide(); });
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); });

View file

@ -1,13 +1,19 @@
{% if candidates and candidates|length %}
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
{% for name, score, colors in candidates %}
{% for cand in candidates %}
<li>
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
hx-get="/build/new/inspect?name={{ name|urlencode }}"
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
{{ name }}
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
{{ cand.display }}
{% if cand.warning %}
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;"></span>
{% endif %}
</button>
{% if cand.warning %}
<div class="muted" style="font-size:11px; margin:.25rem 0 0 .5rem; color:#facc15;" role="note">⚠ {{ cand.warning }}</div>
{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -55,9 +55,9 @@
<fieldset>
<legend>Preferences</legend>
<div style="text-align: left;">
<div style="margin-bottom: 1rem;">
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" style="margin: 0;" />
<div style="margin-bottom: 1rem; display:flex; flex-direction:column; gap:0.75rem;">
<label for="pref-combos-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_combos %}checked{% endif %} />
<span>Prioritize combos</span>
</label>
<div id="pref-combos-config" style="margin-top: 0.5rem; margin-left: 1.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 8px; display: none;">
@ -80,12 +80,24 @@
</div>
</div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" style="margin: 0;" />
<label for="pref-mc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.enable_multicopy %}checked{% endif %} />
<span>Enable Multi-Copy package</span>
</label>
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.75rem;">
<label for="use-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.use_owned_only %}checked{% endif %} />
<span>Use only owned cards</span>
</label>
<label for="prefer-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_owned %}checked{% endif %} />
<span>Prefer owned cards (allow unowned fallback)</span>
</label>
<label for="swap-mdfc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
<span>Swap basics for MDFC lands</span>
</label>
</div>
</div>
</div>
</fieldset>

View file

@ -15,6 +15,27 @@
</script>
</div>
{% set exclusion = commander.exclusion if commander is defined and commander.exclusion is defined else None %}
{% if exclusion %}
{% set eligible_raw = exclusion.eligible_faces if exclusion.eligible_faces is defined else [] %}
{% set eligible_list = eligible_raw if eligible_raw is iterable else [] %}
{% set eligible_lower = eligible_list | map('lower') | list %}
{% set current_lower = commander.name|lower %}
{% if eligible_list and (current_lower not in eligible_lower or exclusion.reason == 'secondary_face_only') %}
<div class="muted" style="font-size:12px; margin-top:.35rem; color:#facc15;" role="note">
{% if eligible_list|length == 1 %}
⚠ This commander only works from '{{ eligible_list[0] }}'.
{% if exclusion.primary_face and exclusion.primary_face|lower != eligible_list[0]|lower %}
Front face '{{ exclusion.primary_face }}' can't lead a deck.
{% endif %}
We'll build using the supported face automatically.
{% else %}
⚠ This commander only works from these faces: {{ eligible_list | join(', ') }}. We'll build using the supported faces automatically.
{% endif %}
</div>
{% endif %}
{% endif %}
<div>
{% if tags and tags|length %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>

View file

@ -30,6 +30,10 @@
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
Prefer owned cards (allow unowned fallback)
</label>
<label style="display:flex; align-items:center; gap:.35rem;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
<input type="checkbox" name="swap_mdfc_basics" value="1" {% if swap_mdfc_basics %}checked{% endif %} onchange="this.form.requestSubmit();" />
Swap basics for MDFC lands
</label>
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
</form>
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>

View file

@ -74,9 +74,10 @@
<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;">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
</div>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
</div>

View file

@ -91,7 +91,7 @@
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
.commander-thumb { width:160px; flex:0 0 auto; }
.commander-thumb { width:160px; flex:0 0 auto; position:relative; }
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }

View file

@ -1,6 +1,7 @@
{# Commander row partial fed by CommanderView entries #}
{% from "partials/_macros.html" import color_identity %}
{% set record = entry.record %}
{% set display_label = record.name if '//' in record.name else record.display_name %}
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
<div class="commander-thumb">
{% set small = record.image_small_url or record.image_normal_url %}
@ -12,12 +13,13 @@
loading="lazy"
decoding="async"
data-card-name="{{ record.display_name }}"
data-original-name="{{ record.name }}"
data-hover-simple="true"
/>
</div>
<div class="commander-main">
<div class="commander-header">
<h3 class="commander-name">{{ record.display_name }}</h3>
<h3 class="commander-name">{{ display_label }}</h3>
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
</div>
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>

View file

@ -12,6 +12,62 @@
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
{% if colors %}
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
<div style="overflow-x:auto">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="border-bottom:1px solid var(--border); text-align:left;">
<th style="padding:.35rem .25rem;">Color</th>
<th style="padding:.35rem .25rem;">Groups merged</th>
<th style="padding:.35rem .25rem;">Faces dropped</th>
<th style="padding:.35rem .25rem;">Multi-face rows</th>
<th style="padding:.35rem .25rem;">Latest entries</th>
</tr>
</thead>
<tbody>
{% for color, payload in colors.items()|dictsort %}
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
<td style="padding:.35rem .25rem;">
{% set entries = payload.entries or [] %}
{% if entries %}
<details>
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
{% for entry in entries %}
{% if loop.index0 < 5 %}
<li style="margin-bottom:.25rem;">
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
</li>
{% elif loop.index0 == 5 %}
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
{% break %}
{% endif %}
{% endfor %}
</ul>
</details>
{% else %}
<span class="muted">No groups recorded</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
{% endif %}
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Performance (local)</h3>
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
@ -193,6 +249,71 @@
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
}
loadTokenStats();
var dfcMetricsEl = document.getElementById('dfcMetrics');
function renderDfcMetrics(payload){
if (!dfcMetricsEl) return;
try {
if (!payload || payload.ok !== true) {
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
return;
}
var metrics = payload.metrics || {};
var html = '';
html += '<div><strong>Deck summaries observed:</strong> ' + String(metrics.total_builds || 0) + '</div>';
var withDfc = Number(metrics.builds_with_mdfc || 0);
var share = metrics.build_share != null ? Number(metrics.build_share) : null;
if (!Number.isNaN(share) && share !== null) {
share = (share * 100).toFixed(1);
} else {
share = null;
}
html += '<div><strong>With MDFCs:</strong> ' + String(withDfc);
if (share !== null) {
html += ' (' + share + '%)';
}
html += '</div>';
var totalLands = Number(metrics.total_mdfc_lands || 0);
var avg = metrics.avg_mdfc_lands != null ? Number(metrics.avg_mdfc_lands) : null;
html += '<div><strong>Total MDFC lands:</strong> ' + String(totalLands);
if (avg !== null && !Number.isNaN(avg)) {
html += ' (avg ' + avg.toFixed(2) + ')';
}
html += '</div>';
var top = metrics.top_cards || {};
var topKeys = Object.keys(top);
if (topKeys.length) {
var items = topKeys.slice(0, 5).map(function(name){
return name + ' (' + String(top[name]) + ')';
});
html += '<div style="font-size:11px;">Top MDFC sources: ' + items.join(', ') + '</div>';
}
var last = metrics.last_summary || {};
if (typeof last.dfc_lands !== 'undefined') {
html += '<div style="font-size:11px; margin-top:0.25rem;">Last summary: ' + String(last.dfc_lands || 0) + ' MDFC lands · total with MDFCs ' + String(last.with_dfc || 0) + '</div>';
}
if (metrics.last_updated) {
html += '<div style="font-size:11px;">Updated: ' + String(metrics.last_updated) + '</div>';
}
dfcMetricsEl.innerHTML = html;
} catch (_){
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
}
}
function loadDfcMetrics(){
if (!dfcMetricsEl) return;
dfcMetricsEl.textContent = 'Loading MDFC metrics…';
fetch('/status/dfc_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
dfcMetricsEl.textContent = 'Diagnostics disabled (metrics unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderDfcMetrics(data); })
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
}
loadDfcMetrics();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');

View file

@ -29,6 +29,8 @@
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
.dfc-thumb-badge { position:absolute; bottom:8px; left:6px; background:rgba(15,23,42,.92); border:1px solid #34d399; color:#bbf7d0; border-radius:12px; font-size:11px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.dfc-thumb-badge.counts { border-color:#60a5fa; color:#bfdbfe; }
.owned-flag { font-size:.95rem; opacity:.9; }
</style>
<div id="typeview-list" class="typeview">
@ -47,8 +49,11 @@
.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 .flip-slot { min-width:2.4em; display:flex; justify-content:flex-start; align-items:center; }
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
.dfc-land-chip { display:inline-flex; align-items:center; gap:.25rem; padding:2px 6px; border-radius:999px; font-size:11px; font-weight:600; background:#0f172a; border:1px solid #334155; color:#e5e7eb; line-height:1; }
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
</style>
<div class="list-grid">
{% for c in clist %}
@ -69,7 +74,11 @@
<span class="count">{{ cnt }}</span>
<span class="times">x</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="flip-slot" aria-hidden="true">
{% if c.dfc_land %}
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
{% endif %}
</span>
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
</div>
{% endfor %}
@ -106,6 +115,9 @@
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</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>
{% if c.dfc_land %}
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
{% endif %}
</div>
{% endfor %}
</div>
@ -122,6 +134,60 @@
<!-- Deck Summary initializer script moved below markup for proper element availability -->
<!-- Land Summary -->
{% set land = summary.land_summary if summary else None %}
{% if land %}
<section style="margin-top:1rem;">
<h5>Land Summary</h5>
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
</div>
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
</div>
{% if land.dfc_cards %}
<details style="margin-top:.5rem;">
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
{% for card in land.dfc_cards %}
{% set extra = card.adds_extra_land or card.counts_as_extra %}
{% set colors = card.colors or [] %}
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
<span>Colors: {{ colors|join(', ') if colors else '' }}</span>
{% if extra %}
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
{% else %}
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
{% endif %}
{% if card.faces %}
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
{% for face in card.faces %}
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
{% set face_type = face.get('type') or '' %}
{% set mana_cost = face.get('mana_cost') %}
{% set mana_value = face.get('mana_value') %}
{% set produces = face.get('produces_mana') %}
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
<span>{{ face_name }}</span>
<span>— {{ face_type }}</span>
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
{% if mana_value is not none %}<span>• MV: {{ mana_value }}</span>{% endif %}
{% if produces %}<span>• Produces mana</span>{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endif %}
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<h5>Mana Overview</h5>
@ -144,7 +210,11 @@
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
{% if c.dfc %}
{% set label = label ~ ' (DFC)' %}
{% endif %}
{% set _ = parts.append(label) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}