feat(web,docs): visual summaries (curve, pips/sources incl. 'C', non‑land sources), tooltip copy, favicon; diagnostics (/healthz, request‑id, global handlers); fetches excluded, basics CSV fallback, list highlight polish; README/DOCKER/release-notes/CHANGELOG updated

This commit is contained in:
matt 2025-08-26 20:00:07 -07:00
parent 625f6abb13
commit 8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions

329
code/web/static/app.js Normal file
View file

@ -0,0 +1,329 @@
/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
(function(){
// Design tokens fallback (in case CSS variables missing in older browsers)
// No-op here since styles.css defines variables; kept for future JS reads.
// State persistence helpers (localStorage + URL hash)
var state = {
get: function(key, def){
try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
},
set: function(key, val){
try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
},
inHash: function(obj){
// Merge obj into location.hash as query-like params
try {
var params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); });
location.hash = params.toString();
} catch(e){}
},
readHash: function(){
try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
}
};
window.__mtgState = state;
// Toast system
var toastHost;
function ensureToastHost(){
if (!toastHost){
toastHost = document.createElement('div');
toastHost.className = 'toast-host';
document.body.appendChild(toastHost);
}
return toastHost;
}
function toast(msg, type, opts){
ensureToastHost();
var t = document.createElement('div');
t.className = 'toast' + (type ? ' '+type : '');
t.setAttribute('role','status');
t.setAttribute('aria-live','polite');
t.textContent = msg;
toastHost.appendChild(t);
var delay = (opts && opts.duration) || 2600;
setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
return t;
}
window.toast = toast;
// Global HTMX error handling => toast
document.addEventListener('htmx:responseError', function(e){
var detail = e.detail || {}; var xhr = detail.xhr || {};
var msg = 'Action failed';
try { if (xhr.responseText) msg += ': ' + xhr.responseText.slice(0,140); } catch(_){}
toast(msg, 'error', { duration: 5000 });
});
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
// Keyboard shortcuts
var keymap = {
' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); },
'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); },
'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); },
'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); },
};
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
var k = e.key.toLowerCase();
if (keymap[k]){ e.preventDefault(); keymap[k](); }
});
// Focus ring visibility for keyboard nav
function addFocusVisible(){
var hadKeyboardEvent = false;
function onKeyDown(){ hadKeyboardEvent = true; }
function onPointer(){ hadKeyboardEvent = false; }
function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); }
function onBlur(e){ e.target.classList.remove('focus-visible'); }
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('mousedown', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('touchstart', onPointer, true);
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onBlur);
}
addFocusVisible();
// Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
function showSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.add('is-loading'); });
}
function hideSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.remove('is-loading'); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
var el = e.target;
if (el && el.matches('[data-pref]')){
var key = el.getAttribute('data-pref');
var val = (el.type === 'checkbox') ? !!el.checked : el.value;
state.set(key, val);
state.inHash((function(o){ o[key] = val; return o; })({}));
}
});
// On load, initialize any data-pref elements
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('[data-pref]').forEach(function(el){
var key = el.getAttribute('data-pref');
var saved = state.get(key, undefined);
if (typeof saved !== 'undefined'){
if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved;
}
});
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
});
// Hydrate progress bars with width based on data-pct
function hydrateProgress(root){
(root || document).querySelectorAll('.progress[data-pct]')
.forEach(function(p){
var pct = parseInt(p.getAttribute('data-pct') || '0', 10);
if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
var bar = p.querySelector('.bar'); if (!bar) return;
// Animate width for a bit of delight
requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
});
}
// Keep hidden inputs for show_skipped in sync with the sticky checkbox
function syncShowSkipped(root){
var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
if (!cb) return;
var val = cb.checked ? '1' : '0';
(root || document).querySelectorAll('section form').forEach(function(f){
var h = f.querySelector('input[name="show_skipped"]');
if (h) h.value = val;
});
}
document.addEventListener('htmx:afterSwap', function(e){
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
});
// --- Card grid filters, reasons, and collapsible groups ---
function initCardFilters(root){
var section = (root || document).querySelector('section');
if (!section) return;
var toolbar = section.querySelector('.cards-toolbar');
if (!toolbar) return; // nothing to do
var q = toolbar.querySelector('input[name="filter_query"]');
var ownedSel = toolbar.querySelector('select[name="filter_owned"]');
var showReasons = toolbar.querySelector('input[name="show_reasons"]');
var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
var resultsEl = toolbar.querySelector('[data-results]');
var emptyEl = section.querySelector('[data-empty]');
var sortSel = toolbar.querySelector('select[name="filter_sort"]');
var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
var chipNot = toolbar.querySelector('[data-chip-owned="not"]');
var chipAll = toolbar.querySelector('[data-chip-owned="all"]');
var chipClear = toolbar.querySelector('[data-chip-clear]');
function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
// Read URL hash on first init to hydrate controls
try {
var params = window.__mtgState.readHash();
if (params){
var hv = params.get('q'); if (q && hv !== null) q.value = hv;
hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
}
} catch(_){}
function apply(){
var query = (getVal(q)+ '').toLowerCase().trim();
var ownedMode = (getVal(ownedSel) || 'all');
var showR = !!getVal(showReasons);
var collapse = !!getVal(collapseGroups);
var sortMode = (getVal(sortSel) || 'az');
// Toggle reasons visibility via section class
section.classList.toggle('hide-reasons', !showR);
// Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid'); if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
if (collapse){
grid.setAttribute('data-collapsed','1');
} else {
// restore stored
if (key){
var stored = state.get('cards:group:'+key, null);
if (stored === true){ grid.setAttribute('data-collapsed','1'); }
else { grid.removeAttribute('data-collapsed'); }
} else {
grid.removeAttribute('data-collapsed');
}
}
});
// Filter tiles
var tiles = section.querySelectorAll('.card-grid .card-tile');
var visible = 0;
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
var role = (tile.getAttribute('data-role')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var owned = tile.getAttribute('data-owned') === '1';
var text = name + ' ' + role + ' ' + tags;
var qOk = !query || text.indexOf(query) !== -1;
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
var show = qOk && oOk;
tile.style.display = show ? '' : 'none';
if (show) visible++;
});
// Sort within each grid
function keyFor(tile){
var name = (tile.getAttribute('data-card-name')||'');
var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
var gc = tile.classList.contains('game-changer') ? 1 : 0;
return { name: name.toLowerCase(), owned: owned, gc: gc };
}
section.querySelectorAll('.card-grid').forEach(function(grid){
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
arr.sort(function(a,b){
var ka = keyFor(a), kb = keyFor(b);
if (sortMode === 'owned'){
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
return ka.name.localeCompare(kb.name);
} else if (sortMode === 'gc'){
if (kb.gc !== ka.gc) return kb.gc - ka.gc;
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
return ka.name.localeCompare(kb.name);
}
// default AZ
return ka.name.localeCompare(kb.name);
});
arr.forEach(function(el){ grid.appendChild(el); });
});
// Update group counts based on visible tiles within each group
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid');
var count = 0;
if (grid){
grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
}
var cEl = wrapper.querySelector('[data-count]');
if (cEl) cEl.textContent = count;
});
if (resultsEl) resultsEl.textContent = String(visible);
if (emptyEl) emptyEl.hidden = (visible !== 0);
// Persist prefs
if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
// Update URL hash for shareability
try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
}
// Wire events
if (q) q.addEventListener('input', apply);
if (ownedSel) ownedSel.addEventListener('change', apply);
if (showReasons) showReasons.addEventListener('change', apply);
if (collapseGroups) collapseGroups.addEventListener('change', apply);
if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
// Individual group toggles
section.querySelectorAll('.group-header .toggle').forEach(function(btn){
btn.addEventListener('click', function(){
var wrapper = btn.closest('.group');
var grid = wrapper && wrapper.querySelector('.group-grid');
if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
var willCollapse = !grid.getAttribute('data-collapsed');
if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
if (key){ state.set('cards:group:'+key, !!willCollapse); }
// ARIA
btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
});
});
// Per-card reason toggle: delegate clicks on .btn-why
section.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.classList || !t.classList.contains('btn-why')) return;
e.preventDefault();
var tile = t.closest('.card-tile');
if (!tile) return;
var globalHidden = section.classList.contains('hide-reasons');
if (globalHidden){
// Force-show overrides global hidden
var on = tile.classList.toggle('force-show');
if (on) tile.classList.remove('force-hide');
t.textContent = on ? 'Hide why' : 'Why?';
} else {
// Hide this tile only
var off = tile.classList.toggle('force-hide');
if (off) tile.classList.remove('force-show');
t.textContent = off ? 'Show why' : 'Hide why';
}
});
// Initial apply on hydrate
apply();
// Keyboard helpers: '/' focuses query, Esc clears
function onKey(e){
// avoid when typing in inputs
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/'){
if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
} else if (e.key === 'Escape'){
if (q && q.value){ q.value=''; apply(); }
}
}
document.addEventListener('keydown', onKey);
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
code/web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -17,12 +17,18 @@
--text: #e8e8e8;
--muted: #b6b8bd;
--border: #2a2b2f;
--ring: #60a5fa; /* focus ring */
--ok: #16a34a; /* success */
--warn: #f59e0b; /* warning */
--err: #ef4444; /* error */
}
*{box-sizing:border-box}
html,body{height:100%}
body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); }
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
/* Accessible focus ring for keyboard navigation */
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* Top banner */
.top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
@ -70,6 +76,29 @@ select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }
/* Toasts */
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
.toast.hide{ opacity:0; transform: translateY(6px); }
.toast.success{ border-color: rgba(22,163,74,.4); }
.toast.error{ border-color: rgba(239,68,68,.45); }
.toast.warn{ border-color: rgba(245,158,11,.45); }
/* Skeletons */
[data-skeleton]{ position: relative; }
[data-skeleton].is-loading > *{ opacity: 0; }
[data-skeleton]::after{
content: '';
position: absolute; inset: 0;
border-radius: 8px;
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
background-size: 200% 100%;
animation: shimmer 1.1s linear infinite;
display: none;
}
[data-skeleton].is-loading::after{ display:block; }
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
@ -143,3 +172,46 @@ small, .muted{ color: var(--muted); }
/* Deck summary: highlight game changers */
.game-changer { color: var(--green-main); }
.stack-card.game-changer { outline: 2px solid var(--green-main); }
/* Stage Navigator */
.stage-nav { margin:.5rem 0 1rem; }
.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
.stage-nav .stage-item.done .stage-link { opacity:.75; }
.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks for small screens */
@media (max-width: 720px){
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
}
/* Progress bar */
.progress { position: relative; height: 10px; background: #0f1115; border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
/* Chips */
.chip { display:inline-flex; align-items:center; gap:.35rem; background:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
/* Cards toolbar */
.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
.cards-toolbar input[type="text"]{ min-width: 220px; }
.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
/* Collapse groups and reason toggle */
.group{ margin:.5rem 0; }
.group-header{ display:flex; align-items:center; gap:.5rem; }
.group-header h5{ margin:.4rem 0; }
.group-header .count{ color: var(--muted); font-size:12px; }
.group-header .toggle{ margin-left:auto; background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
.group-grid[data-collapsed]{ display:none; }
.hide-reasons .card-tile .reason{ display:none; }
.card-tile.force-show .reason{ display:block !important; }
.card-tile.force-hide .reason{ display:none !important; }
.btn-why{ background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
.chips-inline .chip{ cursor:pointer; user-select:none; }