Web: mobile UI polish; Multi-Copy opt-in + tag filter; banner subtitle inline; New Deck modal refinements; version bump to 2.2.4; update release notes template

This commit is contained in:
matt 2025-09-02 16:03:12 -07:00
parent ef858e6d6a
commit 0033f07783
14 changed files with 408 additions and 60 deletions

View file

@ -30,7 +30,7 @@
}catch(_){ }
})();
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
@ -45,7 +45,12 @@
<body 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">
<h1>MTG Deckbuilder</h1>
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
<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);">
☰ Menu
</button>
<h1 style="margin:0;">MTG Deckbuilder</h1>
</div>
<div style="display:flex; align-items:center; gap:.5rem">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
@ -70,7 +75,7 @@
</div>
</header>
<div class="layout">
<aside class="sidebar">
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
<div class="brand">
<div class="mana-dots" aria-hidden="true">
<span class="dot green"></span>
@ -117,9 +122,49 @@
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
footer.site-footer { flex-shrink: 0; }
/* Hide hover preview on narrow screens to avoid covering content */
@media (max-width: 900px){
.card-hover{ display: none !important; }
}
</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);
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(_){ }
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){

View file

@ -1 +1 @@
<div id="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
<div id="banner-status" class="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>

View file

@ -1,6 +1,6 @@
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
<div class="modal-backdrop" style="position:fixed; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
<div class="modal-header">
<h3 id="newDeckTitle">Build a New Deck</h3>
</div>
@ -10,7 +10,7 @@
<form hx-post="/build/new" hx-target="#wizard" hx-swap="innerHTML" hx-on="htmx:afterRequest: (function(evt){ try{ if(evt && evt.detail && evt.detail.elt === this){ var m=this.closest('.modal'); if(m){ m.remove(); } } }catch(_){} }).call(this, event)" autocomplete="off">
<fieldset>
<legend>Basics</legend>
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
<div>
<label style="display:block; margin-bottom:.5rem;">
<span class="muted">Optional name (used for file names)</span>
@ -39,6 +39,7 @@
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
<div style="margin-top:.5rem;">
<label>Bracket
<select name="bracket">
@ -54,6 +55,10 @@
<label 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" /> Prioritize combos (auto-complete partners)
</label>
<div style="margin-top:.35rem;"></div>
<label 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" /> Enable Multi-Copy package
</label>
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<label>
@ -95,14 +100,42 @@
<script>
(function(){
// Backdrop click to close
try{
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
var backdrop = modal ? modal.querySelector('.modal-backdrop') : null;
if (backdrop){ backdrop.addEventListener('click', function(){ try{ modal.remove(); }catch(_){} }); }
}catch(_){ }
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
// Prevent Enter in text inputs from submitting the form
try {
var form = modal ? modal.querySelector('form') : document.querySelector('.modal form');
if (form){
// Prevent Enter in name field from submitting
// Form-level Enter: if suggestions exist, pick the first by default
form.addEventListener('keydown', function(e){
try{
if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
var list = document.getElementById('newdeck-candidates');
var first = list && list.querySelector('button.candidate-btn');
if (first){
e.preventDefault(); e.stopPropagation(); first.click();
} else {
// No suggestions: allow normal form submit
}
}catch(_){ }
}, true);
// Name field: only block Enter if suggestions exist; otherwise allow submit
var nameEl = form.querySelector('input[name="name"]');
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
if (nameEl){
nameEl.addEventListener('keydown', function(e){
if (e.key !== 'Enter') return;
try{
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (hasBtns){ e.preventDefault(); e.stopPropagation(); }
}catch(_){ }
});
}
// In commander field, Enter picks the first candidate (if any) without closing the modal
var cmdEl = form.querySelector('input[name=\"commander\"]');
if (cmdEl){
@ -186,4 +219,62 @@
sync();
} catch(_){}
})();
// Integrated Multi-Copy: fetch suggestions once commander + tags are present
(function(){
function fetchMulti(){
try{
var slot = document.getElementById('newdeck-multicopy-slot');
var form = document.querySelector('.modal form');
if (!slot || !form) return;
var enable = form.querySelector('#pref-mc-chk');
if (!enable || !enable.checked){ slot.innerHTML = ''; return; }
var cmd = form.querySelector('input[name="commander"]').value.trim();
var p = form.querySelector('input[name="primary_tag"]').value.trim();
var s = form.querySelector('input[name="secondary_tag"]').value.trim();
var t = form.querySelector('input[name="tertiary_tag"]').value.trim();
var mode = form.querySelector('input[name="tag_mode"]').value.trim() || 'AND';
if (!cmd) { slot.innerHTML = ''; return; }
// Only fetch if at least one tag is present (to avoid noise)
if (!(p || s || t)) { slot.innerHTML = ''; return; }
var params = new URLSearchParams({ commander: cmd, tag_mode: mode });
if (p) params.append('primary_tag', p); if (s) params.append('secondary_tag', s); if (t) params.append('tertiary_tag', t);
fetch('/build/new/multicopy?' + params.toString(), { headers: { 'HX-Request': 'true' } })
.then(function(r){ return r.text(); })
.then(function(html){ slot.innerHTML = html; })
.catch(function(){ slot.innerHTML = ''; });
}catch(_){ }
}
// Listen for OOB updates to the tags slot to trigger fetch
document.body.addEventListener('htmx:afterSwap', function(ev){
try{
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
if (tgt && tgt.id === 'newdeck-tags-slot'){ fetchMulti(); }
}catch(_){ }
});
// Respond to explicit tag-change signal from the tags partial
try{ document.addEventListener('newdeck:tagsChanged', fetchMulti); }catch(_){ }
// Also debounce on commander input changes
try{
var cmdEl = document.querySelector('.modal form input[name="commander"]');
var timer;
if (cmdEl){ cmdEl.addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(fetchMulti, 300); }); }
}catch(_){ }
// React to preference toggle
try{
var mcChk = document.querySelector('.modal form #pref-mc-chk');
if (mcChk){ mcChk.addEventListener('change', fetchMulti); }
}catch(_){ }
})();
</script>
<style>
/* Modal responsive tweaks (scoped) */
@media (max-width: 720px){
.modal .basics-grid{ grid-template-columns: 1fr !important; }
#newdeck-commander-slot{ max-width: 100% !important; }
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
.modal .modal-content{ width: min(95vw, 560px) !important; }
}
</style>

View file

@ -0,0 +1,59 @@
{% if items and items|length %}
<fieldset id="mc-integrated" style="margin-top:.75rem;">
<legend>Optional: Multi-Copy package</legend>
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
<div style="display:grid; gap:.5rem;">
{% for it in items %}
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
<div>
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
{% if it.reasons %}
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
{% set first = items[0] %}
{% set cap = first.printed_cap %}
{% set rec = first.rec_window if first.rec_window else (20,30) %}
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;">
<label>Copies <input type="number" min="1" name="multi_count" value="{{ first.default_count or 25 }}" style="width:6rem; margin-left:.35rem;"></label>
{% if cap %}
<small class="muted">Max {{ cap }}</small>
{% else %}
<small class="muted">Suggested {{ rec[0] }}{{ rec[1] }}</small>
{% endif %}
</div>
<div id="mc-thrum-row" style="margin-top:.35rem;">
<label title="Adds 1 copy of Thrumming Stone if applicable.">
<input type="checkbox" name="multi_thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
</label>
</div>
<div class="muted" style="font-size:12px; margin-top:.35rem;">You can leave this unselected to skip multi-copy for this build.</div>
</fieldset>
<script>
(function(){
var root = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling : document;
var container = root.querySelector ? root : document;
var fieldset = container.querySelector('#mc-integrated');
if (!fieldset) return;
function updateForChoice(){
try{
var checked = fieldset.querySelector('input[name="multi_choice_id"]:checked');
var count = fieldset.querySelector('input[name="multi_count"]');
if (!checked || !count) return;
// Use label text to parse Cap when present
var label = checked.closest('label.mc-option');
var capEl = label && label.querySelector('.muted');
var m = capEl && capEl.textContent && capEl.textContent.match(/Cap:\s*(\d+)/);
if (m){ var cap = parseInt(m[1],10); count.max = String(cap); if (parseInt(count.value||'0',10) > cap) count.value = String(cap); }
else { count.removeAttribute('max'); }
}catch(_){}
}
fieldset.querySelectorAll('input[name="multi_choice_id"]').forEach(function(r){ r.addEventListener('change', updateForChoice); });
updateForChoice();
})();
</script>
{% endif %}

View file

@ -94,6 +94,8 @@
}catch(_){ }
function apply(container){ if(!container) return; var chips = container.querySelectorAll('button.chip'); chips.forEach(function(btn){ var tag=btn.dataset.tag||''; var active=getSel().indexOf(tag)>=0; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active?'true':'false'); }); }
apply(list); apply(reco);
// Notify parent modal so it can refresh multi-copy suggestions
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
}
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });

View file

@ -8,7 +8,6 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="commander" value="{{ commander.name }}" />

View file

@ -9,7 +9,6 @@
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>

View file

@ -8,7 +8,6 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
{% if locks_restored and locks_restored > 0 %}
<div class="muted" style="margin:.35rem 0;">
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>

View file

@ -26,7 +26,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -137,7 +137,7 @@
</div>
<!-- Sticky build controls on mobile -->
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<div class="build-controls" style="position:sticky; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>