feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)

This commit is contained in:
matt 2025-08-28 14:57:22 -07:00
parent f8c6b5c07e
commit 721e1884af
41 changed files with 2960 additions and 143 deletions

View file

@ -11,6 +11,8 @@ import time
import uuid
import logging
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware
from typing import Any, Tuple
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -18,10 +20,20 @@ _TEMPLATES_DIR = _THIS_DIR / "templates"
_STATIC_DIR = _THIS_DIR / "static"
app = FastAPI(title="MTG Deckbuilder Web UI")
app.add_middleware(GZipMiddleware, minimum_size=500)
# Mount static if present
if _STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
class CacheStatic(StaticFiles):
async def get_response(self, path, scope): # type: ignore[override]
resp = await super().get_response(path, scope)
try:
# Add basic cache headers for static assets
resp.headers.setdefault("Cache-Control", "public, max-age=604800, immutable")
except Exception:
pass
return resp
app.mount("/static", CacheStatic(directory=str(_STATIC_DIR)), name="static")
# Jinja templates
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
@ -35,14 +47,42 @@ def _as_bool(val: str | None, default: bool = False) -> bool:
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
# Expose as Jinja globals so all templates can reference without passing per-view
templates.env.globals.update({
"show_logs": SHOW_LOGS,
"show_setup": SHOW_SETUP,
"show_diagnostics": SHOW_DIAGNOSTICS,
"virtualize": SHOW_VIRTUALIZE,
})
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {}
_FRAGMENT_TTL_SECONDS = 60.0
def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str:
"""Render a template fragment with an optional cache key and short TTL.
Intended for finished/immutable views (e.g., saved deck summaries). On error,
falls back to direct rendering without cache interaction.
"""
try:
if cache_key:
now = time.time()
k = (template_name, str(cache_key))
hit = _FRAGMENT_CACHE.get(k)
if hit and (now - hit[0]) < _FRAGMENT_TTL_SECONDS:
return hit[1]
html = templates.get_template(template_name).render(**ctx)
_FRAGMENT_CACHE[k] = (now, html)
return html
return templates.get_template(template_name).render(**ctx)
except Exception:
return templates.get_template(template_name).render(**ctx)
templates.env.globals["render_cached"] = render_cached
# --- Diagnostics: request-id and uptime ---
_APP_START_TIME = time.time()
@ -331,3 +371,11 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
@app.get("/diagnostics/perf", response_class=HTMLResponse)
async def diagnostics_perf(request: Request) -> HTMLResponse:
"""Synthetic scroll performance page (diagnostics only)."""
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/perf.html", {"request": request})

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from pathlib import Path
import csv
import os
from typing import Dict, List, Tuple
from typing import Dict, List, Tuple, Optional
from ..app import templates
from ..services import owned_store
@ -47,6 +47,8 @@ def _list_decks() -> list[dict]:
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
meta["commander"] = _m.get('commander') or meta.get("commander")
meta["tags"] = _m.get('tags') or meta.get("tags") or []
if _m.get('name'):
meta["display"] = _m.get('name')
except Exception:
pass
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
@ -213,6 +215,38 @@ def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, i
return summary, type_counts, curve_counts, type_cards
def _read_deck_counts(csv_path: Path) -> Dict[str, int]:
"""Read a CSV deck export and return a mapping of card name -> total count.
Falls back to zero on parse issues; ignores header case and missing columns.
"""
counts: Dict[str, int] = {}
try:
with csv_path.open('r', encoding='utf-8') as f:
reader = csv.reader(f)
headers = next(reader, [])
name_idx = headers.index('Name') if 'Name' in headers else 0
count_idx = headers.index('Count') if 'Count' in headers else 1
for row in reader:
if not row:
continue
try:
name = row[name_idx]
except Exception:
continue
try:
cnt = int(float(row[count_idx])) if row[count_idx] else 1
except Exception:
cnt = 1
name = str(name).strip()
if not name:
continue
counts[name] = counts.get(name, 0) + cnt
except Exception:
pass
return counts
@router.get("/", response_class=HTMLResponse)
async def decks_index(request: Request) -> HTMLResponse:
items = _list_decks()
@ -243,11 +277,14 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
_tags = meta.get('tags') or []
if isinstance(_tags, list):
tags = [str(t) for t in _tags]
display_name = meta.get('name') or ''
except Exception:
summary = None
display_name = ''
if not summary:
# Reconstruct minimal summary from CSV
summary, _tc, _cc, _tcs = _read_csv_summary(p)
display_name = ''
stem = p.stem
txt_path = p.with_suffix('.txt')
# If missing still, infer from filename stem
@ -263,7 +300,91 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"summary": summary,
"commander": commander_name,
"tags": tags,
"display_name": display_name,
"game_changers": bc.GAME_CHANGERS,
"owned_set": {n.lower() for n in owned_store.get_names()},
}
return templates.TemplateResponse("decks/view.html", ctx)
@router.get("/compare", response_class=HTMLResponse)
async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[str] = None) -> HTMLResponse:
"""Compare two finished deck CSVs and show diffs.
Query params:
- A: filename of first deck (e.g., Alena_..._20250827.csv)
- B: filename of second deck
"""
base = _deck_dir()
items = _list_decks()
# Build select options with friendly display labels
options: List[Dict[str, str]] = []
for it in items:
label = it.get("display") or it.get("commander") or it.get("name")
# Include mtime for "Latest two" selection refinement
mt = it.get("mtime", 0)
try:
mt_val = str(int(mt))
except Exception:
mt_val = "0"
options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type]
diffs = None
metaA: Dict[str, str] = {}
metaB: Dict[str, str] = {}
if A and B:
pA = (base / A)
pB = (base / B)
if _safe_within(base, pA) and _safe_within(base, pB) and pA.exists() and pB.exists():
ca = _read_deck_counts(pA)
cb = _read_deck_counts(pB)
setA = set(ca.keys())
setB = set(cb.keys())
onlyA = sorted(list(setA - setB))
onlyB = sorted(list(setB - setA))
changed: List[Tuple[str, int, int]] = []
for n in sorted(setA & setB):
if ca.get(n, 0) != cb.get(n, 0):
changed.append((n, ca.get(n, 0), cb.get(n, 0)))
# Side meta (commander/name/tags) if available
def _meta_for(path: Path) -> Dict[str, str]:
out: Dict[str, str] = {"filename": path.name}
sc = path.with_suffix('.summary.json')
try:
if sc.exists():
import json as _json
payload = _json.loads(sc.read_text(encoding='utf-8'))
if isinstance(payload, dict):
m = payload.get('meta', {}) or {}
out["display"] = (m.get('name') or '')
out["commander"] = (m.get('commander') or '')
out["tags"] = ', '.join(m.get('tags') or [])
except Exception:
pass
if not out.get("commander"):
parts = path.stem.split('_')
if parts:
out["commander"] = parts[0]
return out
metaA = _meta_for(pA)
metaB = _meta_for(pB)
diffs = {
"onlyA": onlyA,
"onlyB": onlyB,
"changed": changed,
"A": A,
"B": B,
}
return templates.TemplateResponse(
"decks/compare.html",
{
"request": request,
"options": options,
"A": A or "",
"B": B or "",
"diffs": diffs,
"metaA": metaA,
"metaB": metaB,
},
)

View file

@ -781,6 +781,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception as e:
out(f"Reporting phase failed: {e}")
try:
# If a custom export base is threaded via environment/session in web, we can respect env var
try:
custom_base = os.getenv('WEB_CUSTOM_EXPORT_BASE')
if custom_base:
setattr(b, 'custom_export_base', custom_base)
except Exception:
pass
if hasattr(b, 'export_decklist_csv'):
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
except Exception as e:
@ -819,6 +826,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
"csv": csv_path,
"txt": txt_path,
}
# Attach custom deck name if provided
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -898,6 +912,8 @@ def start_build_ctx(
use_owned_only: bool | None = None,
prefer_owned: bool | None = None,
owned_names: List[str] | None = None,
locks: List[str] | None = None,
custom_export_base: str | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
@ -974,6 +990,9 @@ def start_build_ctx(
"csv_path": None,
"txt_path": None,
"snapshot": None,
"history": [], # list of {i, key, label, snapshot}
"locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()},
"custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None,
}
return ctx
@ -1021,13 +1040,21 @@ def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False) -> Dict[str, Any]:
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False, *, replace: bool = False) -> Dict[str, Any]:
b: DeckBuilder = ctx["builder"]
stages: List[Dict[str, Any]] = ctx["stages"]
logs: List[str] = ctx["logs"]
locks_set: set[str] = set(ctx.get("locks") or [])
# If all stages done, finalize exports (interactive/manual build)
if ctx["idx"] >= len(stages):
# Apply custom export base if present in context
try:
custom_base = ctx.get("custom_export_base")
if custom_base:
setattr(b, 'custom_export_base', str(custom_base))
except Exception:
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
@ -1045,6 +1072,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass
except Exception as e:
logs.append(f"Text export failed: {e}")
# Final lock enforcement before finishing
try:
for lname in locks_set:
try:
# If locked card missing, attempt to add a placeholder entry
if lname not in {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}:
# Try to find exact name in dataframes
target_name = None
try:
df = getattr(b, '_combined_cards_df', None)
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
except Exception:
target_name = None
if target_name is None:
# As fallback, use the locked name as-is (display only)
target_name = lname
b.card_library[target_name] = {
'Count': 1,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
continue
except Exception:
pass
# Build structured summary for UI
summary = None
try:
@ -1067,6 +1124,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -1095,8 +1158,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
label = stage["label"]
runner_name = stage["runner_name"]
# Take snapshot before executing; for rerun, restore first if we have one
if rerun and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
# Take snapshot before executing; for rerun with replace, restore first if we have one
if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
_restore_builder(b, ctx["snapshot"]) # restore to pre-stage state
snap_before = _snapshot_builder(b)
@ -1112,6 +1175,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
logs.append(f"Runner not available: {runner_name}")
delta_log = "\n".join(logs[start_log:])
# Enforce locks immediately after the stage runs so they appear in added list
try:
for lname in locks_set:
try:
lib_keys_lower = {str(n).strip().lower(): str(n) for n in getattr(b, 'card_library', {}).keys()}
if lname not in lib_keys_lower:
# Try to resolve canonical name from DF
target_name = None
try:
df = getattr(b, '_combined_cards_df', None)
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
except Exception:
target_name = None
if target_name is None:
target_name = lname
b.card_library[target_name] = {
'Count': 1,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
continue
except Exception:
pass
# Compute added cards based on snapshot
try:
prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {}
@ -1170,6 +1263,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
added_total = 0
ctx["snapshot"] = snap_before # snapshot for rerun
try:
(ctx.setdefault("history", [])).append({
"i": i + 1,
"key": stage.get("key"),
"label": label,
"snapshot": snap_before,
})
except Exception:
pass
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
return {
@ -1196,6 +1298,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
total_cards = None
ctx["snapshot"] = snap_before
try:
(ctx.setdefault("history", [])).append({
"i": i + 1,
"key": stage.get("key"),
"label": label,
"snapshot": snap_before,
})
except Exception:
pass
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
return {
@ -1210,12 +1321,19 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"added_total": 0,
}
# No cards added and not showing skipped: advance to next
# No cards added and not showing skipped: advance to next stage and continue loop
i += 1
# Continue loop to auto-advance
# If we reached here, all remaining stages were no-ops; finalize exports
ctx["idx"] = len(stages)
# Apply custom export base if present
try:
custom_base = ctx.get("custom_export_base")
if custom_base:
setattr(b, 'custom_export_base', str(custom_base))
except Exception:
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
@ -1255,6 +1373,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)

View file

@ -110,6 +110,13 @@
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 focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
try {
var active = document.activeElement;
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
return;
}
} catch(_) { /* noop */ }
if (keymap[k]){ e.preventDefault(); keymap[k](); }
});
@ -165,6 +172,7 @@
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
initVirtualization(document);
});
// Hydrate progress bars with width based on data-pct
@ -192,8 +200,31 @@
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
initVirtualization(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
function scrollCardIntoView(name){
if (!name) return;
try{
var section = document.querySelector('section');
var grid = section && section.querySelector('.card-grid');
if (!grid) return;
// If virtualized, force a render around the approximate match by searching stored children
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
if (!target) {
// Trigger a render update and try again
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
}
if (target) {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.focus && target.focus();
}
}catch(_){}
}
window.scrollCardIntoView = scrollCardIntoView;
// --- Card grid filters, reasons, and collapsible groups ---
function initCardFilters(root){
var section = (root || document).querySelector('section');
@ -250,7 +281,7 @@
}
});
// Filter tiles
var tiles = section.querySelectorAll('.card-grid .card-tile');
var tiles = section.querySelectorAll('.card-grid .card-tile');
var visible = 0;
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
@ -272,7 +303,7 @@
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'));
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'){
@ -368,4 +399,268 @@
}
document.addEventListener('keydown', onKey);
}
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
function initVirtualization(root){
try{
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
// Global diagnostics aggregator
var GLOBAL = (function(){
if (!DIAG) return null;
if (window.__virtGlobal) return window.__virtGlobal;
var store = { grids: [], summaryEl: null };
function ensure(){
if (!store.summaryEl){
var el = document.createElement('div');
el.id = 'virt-global-diag';
el.style.position = 'fixed';
el.style.right = '8px';
el.style.bottom = '8px';
el.style.background = 'rgba(17,24,39,.85)';
el.style.border = '1px solid var(--border)';
el.style.padding = '.25rem .5rem';
el.style.borderRadius = '6px';
el.style.fontSize = '12px';
el.style.color = '#cbd5e1';
el.style.zIndex = '50';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
el.style.cursor = 'default';
// Hidden by default; toggle with 'v'
el.style.display = 'none';
document.body.appendChild(el);
store.summaryEl = el;
}
return store.summaryEl;
}
function update(){
var el = ensure(); if (!el) return;
var g = store.grids;
var total = 0, visible = 0, lastMs = 0;
for (var i=0;i<g.length;i++){
total += g[i].total||0;
visible += (g[i].end||0) - (g[i].start||0);
lastMs = Math.max(lastMs, g[i].lastMs||0);
}
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
}
function register(gridId, ref){
store.grids.push({ id: gridId, ref: ref });
update();
return {
set: function(stats){
for (var i=0;i<store.grids.length;i++){
if (store.grids[i].id === gridId){
store.grids[i] = Object.assign({ id: gridId, ref: ref }, stats);
break;
}
}
update();
},
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
};
}
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
return window.__virtGlobal;
})();
// Support card grids and other scroll containers (e.g., #owned-box)
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
if (!grids.length) return;
grids.forEach(function(grid){
if (grid.__virtBound) return;
grid.__virtBound = true;
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
var container = grid;
container.style.position = container.style.position || 'relative';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
// Ensure wrapper itself is a grid to preserve multi-column layout inside
// when the container (e.g., .card-grid) is virtualized.
wrapper.style.display = 'grid';
// Move children into a fragment store (for owned, children live under UL)
var source = container;
// If this is the owned box, use the UL inside as the source list
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
var all = Array.prototype.slice.call(source.children);
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });
var padTop = document.createElement('div');
var padBottom = document.createElement('div');
padTop.style.height = '0px'; padBottom.style.height = '0px';
// For owned, keep the UL but render into it; otherwise append wrapper to container
if (ownedGrid){
ownedGrid.innerHTML = '';
ownedGrid.appendChild(padTop);
ownedGrid.appendChild(wrapper);
ownedGrid.appendChild(padBottom);
ownedGrid.appendChild(store);
} else {
container.appendChild(wrapper);
container.appendChild(padBottom);
container.appendChild(store);
}
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
var perRow = 1;
// Optional diagnostics overlay
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
var renderCount = 0; var measureCount = 0; var swapCount = 0;
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
function ensureDiag(){
if (!DIAG) return null;
if (diagBox) return diagBox;
diagBox = document.createElement('div');
diagBox.className = 'virt-diag';
diagBox.style.position = 'sticky';
diagBox.style.top = '0';
diagBox.style.zIndex = '5';
diagBox.style.background = 'rgba(17,24,39,.85)';
diagBox.style.border = '1px solid var(--border)';
diagBox.style.padding = '.25rem .5rem';
diagBox.style.borderRadius = '6px';
diagBox.style.fontSize = '12px';
diagBox.style.margin = '0 0 .35rem 0';
diagBox.style.color = '#cbd5e1';
diagBox.style.display = 'none'; // hidden until toggled
// Controls
var controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '.35rem';
controls.style.alignItems = 'center';
controls.style.marginBottom = '.25rem';
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
btnCopy.addEventListener('click', function(){ try{ var payload = {
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
});
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
diagBox.appendChild(controls);
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
host.insertBefore(diagBox, host.firstChild);
return diagBox;
}
function measure(){
try {
measureCount++;
// create a temp tile to measure if none
var probe = store.firstElementChild || all[0];
if (probe){
var fake = probe.cloneNode(true);
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
(ownedGrid || container).appendChild(fake);
var rect = fake.getBoundingClientRect();
rowH = Math.max(120, Math.ceil(rect.height) + 16);
(ownedGrid || container).removeChild(fake);
}
// Estimate perRow via computed styles of grid
var style = window.getComputedStyle(ownedGrid || container);
var cols = style.getPropertyValue('grid-template-columns');
// Mirror grid settings onto the wrapper so its children still flow in columns
try {
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
if (gap && gap.trim()) wrapper.style.gap = gap;
// Inherit justify/align if present
var ji = style.getPropertyValue('justify-items');
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
var ai = style.getPropertyValue('align-items');
if (ai && ai.trim()) wrapper.style.alignItems = ai;
} catch(_) {}
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
} catch(_){}
}
measure();
var total = all.length;
var start = 0, end = 0;
function render(){
var t0 = DIAG ? performance.now() : 0;
var scroller = container;
var vh = scroller.clientHeight || window.innerHeight;
var scrollTop = scroller.scrollTop;
// If container isnt scrollable, use window scroll offset
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
var newStart = rowStart * perRow;
var newEnd = Math.min(total, rowEnd * perRow);
if (newStart === start && newEnd === end) return; // no change
start = newStart; end = newEnd;
// Padding
var beforeRows = Math.floor(start / perRow);
var afterRows = Math.ceil((total - end) / perRow);
padTop.style.height = (beforeRows * rowH) + 'px';
padBottom.style.height = (afterRows * rowH) + 'px';
// Render visible children
wrapper.innerHTML = '';
for (var i = start; i < end; i++) {
var node = all[i];
if (node) wrapper.appendChild(node);
}
if (DIAG){
var box = ensureDiag();
if (box){
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
var textEl = box.querySelector('.virt-diag-text');
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
textEl.textContent = msg;
// Health hint
var bad = (dt > 33) || (vis > 300);
var warn = (!bad) && ((dt > 16) || (vis > 200));
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
}
}
}
function onScroll(){ render(); }
function onResize(){ measure(); render(); }
container.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// Initial size; ensure container is scrollable for our logic
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
container.style.overflow = container.style.overflow || 'auto';
render();
// Re-render after filters resort or HTMX swaps
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
// Keyboard toggle for overlays: 'v'
if (DIAG && !window.__virtHotkeyBound){
window.__virtHotkeyBound = true;
document.addEventListener('keydown', function(e){
try{
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key && e.key.toLowerCase() === 'v'){
e.preventDefault();
// Toggle all virt-diag boxes and the global summary
var shown = null;
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
}
}catch(_){ }
});
}
});
}catch(_){ }
}
// LQIP blur/fade-in for thumbnails marked with data-lqip
document.addEventListener('DOMContentLoaded', function(){
try{
document.querySelectorAll('img[data-lqip]')
.forEach(function(img){
img.classList.add('lqip');
img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
});
}catch(_){ }
});
})();

View file

@ -130,6 +130,11 @@ small, .muted{ color: var(--muted); }
text-align:center;
}
.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
.card-tile.locked{
/* Subtle yellow/goldish-white accent for locked cards */
border-color: #f5e6a8; /* soft parchment gold */
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
}
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
@ -175,6 +180,9 @@ small, .muted{ color: var(--muted); }
.game-changer { color: var(--green-main); }
.stack-card.game-changer { outline: 2px solid var(--green-main); }
/* Image button inside card tiles */
.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
/* 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; }
@ -221,3 +229,19 @@ small, .muted{ color: var(--muted); }
/* Inline error banner */
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
.inline-error-banner .muted{ color:#fda4af; }
/* Alternatives panel */
.alts ul{ list-style:none; padding:0; margin:0; }
.alts li{ display:flex; align-items:center; gap:.4rem; }
/* LQIP blur/fade-in for thumbnails */
img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
img.lqip.loaded { filter: blur(0); opacity: 1; }
/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
}
/* Virtualization wrapper should mirror grid to keep multi-column flow */
.virt-wrapper { display: grid; }

View file

@ -6,18 +6,23 @@
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
</head>
<body>
<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">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
<button type="button" class="btn" style="margin-left:.5rem;" title="Open a saved permalink"
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
</div>
</div>
</header>
@ -261,7 +266,7 @@
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script src="/static/app.js?v=20250826-2"></script>
<script src="/static/app.js?v=20250826-4"></script>
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){

View file

@ -1 +1 @@
<div id="banner-status" hx-swap-oob="true">{% if step %}<span class="muted">{{ step }}{% if i is not none and n is not none %} ({{ i }}/{{ n }}){% endif %}</span>: {% endif %}{% if commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} - {{ tags|join(', ') }}{% endif %}</div>
<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>

View file

@ -0,0 +1,18 @@
{% 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 %}
<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 }}"
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 }}
</button>
</li>
{% endfor %}
</ul>
{% else %}
{% if query %}
<div class="muted">No matches for “{{ query }}”.</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,149 @@
<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-header">
<h3 id="newDeckTitle">Build a New Deck</h3>
</div>
{% if error %}
<div class="error" role="alert" style="margin:.35rem 0 .5rem 0;">{{ error }}</div>
{% endif %}
<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>
<label style="display:block; margin-bottom:.5rem;">
<span class="muted">Optional name (used for file names)</span>
<input type="text" name="name" placeholder="e.g., Inti Discard Tempo" autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label style="display:block; margin-bottom:.5rem;">
<span>Commander</span>
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
hx-get="/build/new/candidates" hx-trigger="input changed delay:150ms" hx-target="#newdeck-candidates" hx-sync="this:replace" />
</label>
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>
</div>
<div id="newdeck-commander-slot" class="muted" style="max-width:230px;">
<em style="font-size:12px;">Pick a commander to preview here.</em>
</div>
</div>
</fieldset>
<fieldset>
<legend>Themes</legend>
<div id="newdeck-tags-slot" class="muted">
<em>Select a commander to see theme recommendations and choices.</em>
<input type="hidden" name="primary_tag" />
<input type="hidden" name="secondary_tag" />
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div style="margin-top:.5rem;">
<label>Bracket
<select name="bracket">
{% for b in brackets %}
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
{% endfor %}
</select>
</label>
</div>
</fieldset>
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
{% for key, label in labels.items() %}
<label>{{ label }}
<input type="number" name="{{ key }}" value="{{ defaults[key] }}" min="0" />
</label>
{% endfor %}
</div>
</details>
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<button type="submit" class="btn-continue">Create</button>
</div>
</form>
</div>
</div>
<script>
(function(){
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
var nameEl = form.querySelector('input[name="name"]');
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
// In commander field, Enter picks the first candidate (if any) without closing the modal
var cmdEl = form.querySelector('input[name=\"commander\"]');
if (cmdEl){
function handleEnterNav(e){
// Enter selects the highlighted (or first) suggestion
var list = document.getElementById('newdeck-candidates');
var btns = list ? Array.prototype.slice.call(list.querySelectorAll('button.candidate-btn')) : [];
var getActiveIndex = function(){ return btns.findIndex(function(b){ return b.classList.contains('active'); }); };
if (!btns.length) return; // nothing to do, but we've already prevented default
// Skip if a request is in-flight to avoid fighting with swap timing
try{ if (cmdEl.matches('.htmx-request') || list.matches('.htmx-request')) return; }catch(_){ }
if (e.key === 'Enter'){
var idx = getActiveIndex();
var target = btns[(idx >= 0 ? idx : 0)];
if (target) { target.click(); }
}
}
// Capture keydown early to prevent submit on Enter (arrows left to default behavior)
cmdEl.addEventListener('keydown', function(e){
if (e.key === 'Enter'){
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (hasBtns){
e.preventDefault();
e.stopPropagation();
handleEnterNav(e);
}
}
}, true);
// Defensive: also block Enter on keyup (in case a browser tries to submit on keyup)
cmdEl.addEventListener('keyup', function(e){ if (e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); } });
// Global fallback: capture keydown at the document level so Enter never slips through when the commander input is focused
document.addEventListener('keydown', function(e){
try{
if (document.activeElement !== cmdEl) return;
if (e.key !== 'Enter') return;
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (!hasBtns) return;
e.preventDefault();
e.stopPropagation();
handleEnterNav(e);
}catch(_){ }
}, true);
// Reset candidate highlight when the list updates
document.body.addEventListener('htmx:afterSwap', function(ev){
try {
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
if (!tgt) return;
if (tgt.id === 'newdeck-candidates'){
var first = tgt.querySelector('button.candidate-btn');
if (first){
// Clear any lingering active classes, then set the first as active for immediate Enter selection
tgt.querySelectorAll('button.candidate-btn').forEach(function(b){ b.classList.remove('active'); b.setAttribute('aria-selected','false'); });
first.classList.add('active');
first.setAttribute('aria-selected','true');
try{ cmdEl.setAttribute('aria-activedescendant', first.id || ''); }catch(_){ }
}
}
} catch(_){}
});
}
}
} catch(_){ }
// Close on Escape
function closeModal(){ try{ var m = document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){} }
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
document.addEventListener('keydown', onKey);
})();
</script>

View file

@ -0,0 +1,105 @@
{% set pname = commander.name %}
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ pname|urlencode }}&format=image&version=normal" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
</a>
</aside>
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
<script>
try {
var nm = document.querySelector('input[name="name"]');
var value = document.querySelector('#newdeck-commander-slot [data-card-name]')?.getAttribute('data-card-name') || '{{ pname|e }}';
if (nm && (!nm.value || !nm.value.trim())) { nm.value = value; }
} catch(_) {}
</script>
</div>
<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>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
<span class="muted" style="font-size:12px;">Combine</span>
<div role="group" aria-label="Combine mode">
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
<input type="radio" name="combine_mode_radio" value="AND" checked /> AND
</label>
<label title="OR treats your themes as a union (broader pool, fills easier).">
<input type="radio" name="combine_mode_radio" value="OR" /> OR
</label>
</div>
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
</div>
{% if recommended and recommended|length %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
</div>
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
{% endfor %}
</div>
{% else %}
<p class="muted">No theme tags available for this commander.</p>
{% endif %}
<!-- hidden inputs that the main modal form will submit -->
<input type="hidden" name="primary_tag" id="modal_primary_tag" />
<input type="hidden" name="secondary_tag" id="modal_secondary_tag" />
<input type="hidden" name="tertiary_tag" id="modal_tertiary_tag" />
<input type="hidden" name="tag_mode" id="modal_tag_mode" value="AND" />
<div id="modal-selected-themes" class="muted" style="font-size:12px; margin-top:.5rem;">
<em>No themes selected yet.</em>
</div>
</div>
<script>
(function(){
var list = document.getElementById('modal-tag-list');
var reco = document.getElementById('modal-tag-reco');
var selAll = document.getElementById('modal-reco-select-all');
var resetBtn = document.getElementById('modal-reset-tags');
var p = document.getElementById('modal_primary_tag');
var s = document.getElementById('modal_secondary_tag');
var t = document.getElementById('modal_tertiary_tag');
var mode = document.getElementById('modal_tag_mode');
var countEl = document.getElementById('modal-tag-count');
var selSummary = document.getElementById('modal-selected-themes');
if (!list) return;
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
function toggle(tag){ var cur=getSel(); var i=cur.indexOf(tag); if(i>=0){cur.splice(i,1);} else { if(cur.length>=3){cur=cur.slice(1);} cur.push(tag);} setSel(cur); }
function updateUI(){
try{ if(countEl) countEl.textContent = getSel().length + ' / 3 selected'; }catch(_){ }
try{
if(selSummary){
var sel = getSel();
if(!sel.length){ selSummary.innerHTML = '<em>No themes selected yet.</em>'; }
else {
var parts = [];
sel.forEach(function(tag, idx){ parts.push((idx+1) + '. ' + tag); });
selSummary.textContent = 'Selected: ' + parts.join(' · ');
}
}
}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);
}
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); }); });
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
updateUI();
})();
</script>

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 2 %}{% set step_total = 5 %}
<h3>Step 2: Tags & Bracket</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander.name }}">
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,7 @@
</a>
</aside>
<div class="grow" data-skeleton>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
<div hx-get="/build/banner" hx-trigger="load"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="commander" value="{{ commander.name }}" />
@ -95,7 +93,7 @@
</form>
<div style="margin-top:.5rem;">
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>
@ -116,6 +114,7 @@
var countEl = document.getElementById('tag-count');
var orderEl = document.getElementById('tag-order');
var commander = '{{ commander.name|e }}';
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
if (!chipHost) return;
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
@ -158,6 +157,13 @@
}
function loadPersisted(){
try {
// If this page load follows a fresh commander confirmation, wipe persisted values.
if (clearPersisted){
try {
localStorage.removeItem(storageKey('tags'));
localStorage.removeItem(storageKey('mode'));
} catch(_){ }
}
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 3 %}{% set step_total = 5 %}
<h3>Step 3: Ideal Counts</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,7 @@
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></div>
@ -37,7 +35,7 @@
</div>
</form>
<div style="margin-top:.5rem;">
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 4 %}{% set step_total = 5 %}
<h3>Step 4: Review</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,12 @@
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></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>
</div>
{% endif %}
<h4>Chosen Ideals</h4>
<ul>
{% for key, label in labels.items() %}
@ -27,12 +30,13 @@
</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>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
</form>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>

View file

@ -1,10 +1,11 @@
<section>
{% set step_index = 5 %}{% set step_total = 5 %}
<h3>Step 5: Build</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
sizes="(max-width: 900px) 100vw, 320px" />
</a>
{% if status and status.startswith('Build complete') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
@ -24,8 +25,7 @@
{% endif %}
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -39,7 +39,7 @@
</div>
<p>Bracket: {{ bracket }}</p>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
{% if i and n %}
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
{% endif %}
@ -48,6 +48,10 @@
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
</div>
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
{% set pct_clamped = (pct if pct <= 100 else 100) %}
@ -62,7 +66,31 @@
</div>
{% endif %}
<!-- Filters toolbar -->
{% if locked_cards is defined and locked_cards %}
<details id="locked-section" style="margin-top:.5rem;">
<summary>Locked cards (always kept)</summary>
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
{% for lk in locked_cards %}
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
<input type="hidden" name="name" value="{{ lk.name }}" />
<input type="hidden" name="locked" value="0" />
<input type="hidden" name="from_list" value="1" />
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<!-- Last action chip (oob-updated) -->
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
<!-- Filters toolbar -->
<div class="cards-toolbar">
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
<select name="filter_owned" data-pref="cards:owned">
@ -92,11 +120,11 @@
</div>
</div>
<!-- Sticky build controls on mobile -->
<!-- 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;">
<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('Starting build…'); }catch(_){}">
<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">Start Build</button>
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
@ -106,6 +134,23 @@
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
</form>
<span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle">
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
Replace stage picks
</label>
</form>
</div>
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
</form>
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
</form>
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
@ -115,7 +160,24 @@
</div>
{% if added_cards is not none %}
<h4 style="margin-top:1rem;">Cards added this stage</h4>
{% if history is defined and history %}
<details style="margin-top:.5rem;">
<summary>Stage timeline</summary>
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
{% for h in history %}
<li style="display:flex; align-items:center; gap:.5rem;">
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<input type="hidden" name="to" value="{{ h.i }}" />
<button type="submit" class="btn">Go</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<h4 style="margin-top:1rem;">Cards added this stage</h4>
{% if skipped and (not added_cards or added_cards|length == 0) %}
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
{% endif %}
@ -127,6 +189,7 @@
{% if stage_label and stage_label.startswith('Creatures') %}
{% set groups = added_cards|groupby('sub_role') %}
{% for g in groups %}
{% set group_idx = loop.index0 %}
{% set role = g.grouper %}
{% if role %}
{% set heading = 'Theme: ' + role.title() %}
@ -139,46 +202,81 @@
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
</div>
<div class="card-grid group-grid" data-skeleton>
{% for c in g.list %}
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
{% for c in g.list %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% 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' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img 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 }}" />
</a>
{% 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){}})()">
<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 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 }}{% 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;">
<button type="button" class="btn-lock" 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="closest .lock-box" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
</div>
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card-grid" data-skeleton>
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
{% for c in added_cards %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% 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' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img 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 }}" />
</a>
{% 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){}})()">
<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 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 }}{% 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;">
<button type="button" class="btn-lock" 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="closest .lock-box" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
</div>
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
</div>
{% 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 data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
No cards match your filters.
</div>
@ -201,3 +299,67 @@
</div>
</div>
</section>
<script>
// Sync tile class and image-button toggle 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 */}
}
}
}catch(_){/* noop */}
});
// Allow dismissing/auto-clearing the last-action chip
document.addEventListener('click', function(ev){
try{
var t = ev.target;
if (!t) return;
if (t.matches && t.matches('#last-action .chip')){
var c = document.getElementById('last-action');
if (c) c.innerHTML = '';
}
}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{
if (e.ctrlKey || e.metaKey || e.altKey) return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
// Ignore when typing in inputs/selects
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
if (!tile) return;
if (e.key === 'l' || e.key === 'L') {
e.preventDefault(); e.stopPropagation();
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
if (lockFormBtn) { lockFormBtn.click(); }
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault(); e.stopPropagation();
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
if (altBtn) { altBtn.click(); }
}
}catch(_){ }
});
</script>

View file

@ -2,24 +2,12 @@
{% block banner_subtitle %}Build a Deck{% endblock %}
{% block content %}
<h2>Build a Deck</h2>
<div style="margin:.25rem 0 1rem 0;">
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
</div>
<div id="wizard">
{% set step = last_step or 1 %}
{% if step == 1 %}
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
{% elif step == 2 %}
<div hx-get="/build/step2" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=2&n=5" hx-trigger="load"></div>
{% elif step == 3 %}
<div hx-get="/build/step3" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=3&n=5" hx-trigger="load"></div>
{% elif step == 4 %}
<div hx-get="/build/step4" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=4&n=5" hx-trigger="load"></div>
{% else %}
<div hx-get="/build/step5" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=5&n=5" hx-trigger="load"></div>
{% endif %}
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
<!-- Wizard content will load here after the modal submit starts the build. -->
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
</div>
{% endblock %}

View file

@ -39,7 +39,7 @@
{% if summary %}
{% include "partials/deck_summary.html" %}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% endif %}
{% endif %}

View file

@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block banner_subtitle %}Compare Decks{% endblock %}
{% block content %}
<h2>Compare Decks</h2>
<p class="muted">Pick two finished decks to compare. You can get here from Finished Decks or deck view pages.</p>
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label>Deck A
<select name="A" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<label>Deck B
<select name="B" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Compare</button>
<button type="button" id="cmp-swap" class="btn" title="Swap A and B" style="margin-left:.25rem;">Swap A/B</button>
<button type="button" id="cmp-latest" class="btn" title="Pick the latest two decks">Latest two</button>
</form>
{% if diffs %}
<div class="panel" style="margin-top:.75rem;">
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:center;">
<div>
<strong>A:</strong> {{ metaA.display or metaA.filename }}
{% if metaA.commander %}<span class="muted">({{ metaA.commander }})</span>{% endif %}
{% if metaA.tags %}<div class="muted">{{ metaA.tags }}</div>{% endif %}
</div>
<div>
<strong>B:</strong> {{ metaB.display or metaB.filename }}
{% if metaB.commander %}<span class="muted">({{ metaB.commander }})</span>{% endif %}
{% if metaB.tags %}<div class="muted">{{ metaB.tags }}</div>{% endif %}
</div>
<div style="margin-left:auto; display:flex; gap:.5rem; align-items:center;">
<button type="button" id="cmp-copy" class="btn" title="Copy a plain-text summary of the diffs">Copy summary</button>
<button type="button" id="cmp-download" class="btn" title="Download a plain-text summary of the diffs">Download .txt</button>
</div>
</div>
</div>
<div class="panel" style="margin-top:.5rem; display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<div class="muted">Totals:
<strong id="totA">{{ (diffs.onlyA|length) if diffs.onlyA else 0 }}</strong> only-in-A,
<strong id="totB">{{ (diffs.onlyB|length) if diffs.onlyB else 0 }}</strong> only-in-B,
<strong id="totC">{{ (diffs.changed|length) if diffs.changed else 0 }}</strong> changed
</div>
<label class="muted" style="margin-left:auto; display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" id="cmp-changed-only" /> Changed only
</label>
</div>
<div class="only-panels" style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top:.75rem;">
<div class="panel onlyA">
<h3 style="margin-top:0;">Only in A</h3>
{% if diffs.onlyA and diffs.onlyA|length %}
<ul>
{% for n in diffs.onlyA %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<div class="panel onlyB">
<h3 style="margin-top:0;">Only in B</h3>
{% if diffs.onlyB and diffs.onlyB|length %}
<ul>
{% for n in diffs.onlyB %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
</div>
<div class="panel" style="margin-top:1rem;">
<h3 style="margin-top:0;">Changed counts</h3>
{% if diffs.changed and diffs.changed|length %}
<ul class="changed-list">
{% for n, a, b in diffs.changed %}
{% set delta = b - a %}
{% if delta > 0 %}
<li class="chg inc" title="Increased in B">▲ {{ n }}: A={{ a }}, B={{ b }} (+{{ delta }})</li>
{% elif delta < 0 %}
<li class="chg dec" title="Decreased in B">▼ {{ n }}: A={{ a }}, B={{ b }} ({{ delta }})</li>
{% else %}
<li class="chg">{{ n }}: A={{ a }}, B={{ b }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<script id="cmp-data" type="application/json">{{ {
'aLabel': (metaA.display or metaA.filename),
'bLabel': (metaB.display or metaB.filename),
'onlyA': diffs.onlyA or [],
'onlyB': diffs.onlyB or [],
'changed': diffs.changed or []
} | tojson }}</script>
<script>
(function(){
var copyBtn = document.getElementById('cmp-copy');
var dlBtn = document.getElementById('cmp-download');
var changedOnly = document.getElementById('cmp-changed-only');
var dataEl = document.getElementById('cmp-data');
var data = null;
try { data = JSON.parse((dataEl && dataEl.textContent) ? dataEl.textContent : 'null'); } catch(e) { data = null; }
function buildLines(){
var lines = [];
lines.push('Compare:');
lines.push('A: ' + data.aLabel);
lines.push('B: ' + data.bLabel);
lines.push('');
if (!changedOnly || !changedOnly.checked) {
lines.push('Only in A:');
if (data.onlyA && data.onlyA.length) { data.onlyA.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
lines.push('Only in B:');
if (data.onlyB && data.onlyB.length) { data.onlyB.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
}
lines.push('Changed counts:');
if (data.changed && data.changed.length) {
data.changed.forEach(function(row){ lines.push('- ' + row[0] + ': A=' + row[1] + ', B=' + row[2]); });
} else { lines.push('(none)'); }
return lines;
}
if (copyBtn) copyBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt); }
else {
var ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); }catch(_){} document.body.removeChild(ta);
}
if (window.toast) window.toast('Copied comparison');
}catch(_){ }
});
if (dlBtn) dlBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
var blob = new Blob([txt], {type:'text/plain'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'compare.txt';
document.body.appendChild(a);
a.click();
setTimeout(function(){ try{ URL.revokeObjectURL(url); document.body.removeChild(a); }catch(_){ } }, 0);
}catch(_){ }
});
function applyChangedOnlyFlag(){
try{
var wrap = document.querySelector('.only-panels');
if (!wrap || !changedOnly) return;
wrap.style.display = changedOnly.checked ? 'none' : 'grid';
}catch(_){ }
}
if (changedOnly) {
try {
var saved = localStorage.getItem('compare:changedOnly');
if (saved === '1') { changedOnly.checked = true; }
} catch(_){ }
applyChangedOnlyFlag();
changedOnly.addEventListener('change', function(){
try { localStorage.setItem('compare:changedOnly', this.checked ? '1' : '0'); } catch(_){ }
applyChangedOnlyFlag();
});
}
// Swap A/B
var swapBtn = document.getElementById('cmp-swap');
if (swapBtn) swapBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var aVal = a.value, bVal = b.value;
a.value = bVal; b.value = aVal;
f.requestSubmit();
}catch(_){ }
});
// Pick latest two by mtime from options metadata
var latestBtn = document.getElementById('cmp-latest');
if (latestBtn) latestBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var opts = Array.from(a.querySelectorAll('option[value]')).filter(function(o){ return o.value; });
opts.sort(function(x,y){
var mx = parseInt(x.getAttribute('data-mtime') || '0', 10);
var my = parseInt(y.getAttribute('data-mtime') || '0', 10);
return (my - mx);
});
if (opts.length >= 2){
var first = opts[0].value;
var second = opts[1].value;
a.value = first; b.value = second;
f.requestSubmit();
}
}catch(_){ }
});
})();
</script>
<style>
.changed-list { list-style: none; padding-left: 0; }
.changed-list .chg { padding: 2px 0; }
.changed-list .chg.inc { color: #10b981; }
.changed-list .chg.dec { color: #ef4444; }
.only-panels .onlyA h3 { color: #60a5fa; }
.only-panels .onlyB h3 { color: #f59e0b; }
</style>
{% endif %}
{% endblock %}

View file

@ -24,6 +24,10 @@
</label>
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
<span id="deck-count" class="muted" aria-live="polite"></span>
@ -34,11 +38,16 @@
{% if items %}
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
{% for it in items %}
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
<div>
<div>
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
{% if it.display %}
<strong>{{ it.display }}</strong>
<div class="muted" style="font-size:12px;">Commander: <span data-card-name="{{ it.commander }}">{{ it.commander }}</span></div>
{% else %}
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
{% endif %}
</div>
{% if it.tags and it.tags|length %}
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
@ -50,6 +59,10 @@
</div>
</div>
<div style="display:flex; gap:.35rem; align-items:center;">
<label title="Select deck for comparison" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
<span class="muted" style="font-size:12px;">Select</span>
</label>
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.path }}" />
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
@ -112,11 +125,22 @@
var helpClose = document.getElementById('deck-help-close');
var helpBackdrop = document.getElementById('deck-help-backdrop');
var txtOnlyCb = document.getElementById('deck-txt-only');
var cmpSelBtn = document.getElementById('deck-compare-selected');
var cmpLatestBtn = document.getElementById('deck-compare-latest');
var openPermalinkBtn = document.getElementById('deck-open-permalink');
if (!list) return;
// Panels and themes discovery from data-tags-pipe
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
// Selection state for compare
var selected = new Set();
function updateCompareButtons(){
if (!cmpSelBtn) return;
var size = selected.size;
cmpSelBtn.disabled = (size !== 2);
if (cmpSelBtn) cmpSelBtn.title = (size === 2 ? 'Compare the two selected decks' : 'Select exactly two decks to enable');
}
var themeSet = new Set();
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
@ -309,6 +333,31 @@
updateHashFromState();
}
// Wire up compare selection checkboxes
function attachSelectHandlers(){
try {
var cbs = Array.prototype.slice.call(list.querySelectorAll('input.deck-select'));
cbs.forEach(function(cb){
// Initialize checked state based on current selection
var row = cb.closest('.panel');
var name = row ? (row.dataset.name || '') : '';
cb.checked = selected.has(name);
// Apply visual state on init
if (row) row.classList.toggle('selected', cb.checked);
cb.addEventListener('change', function(){
if (!name) return;
if (cb.checked) { selected.add(name); }
else { selected.delete(name); }
// Toggle selection highlight
if (row) row.classList.toggle('selected', cb.checked);
updateCompareButtons();
});
});
updateCompareButtons();
} catch(_){}
}
attachSelectHandlers();
// Debounce helper
function debounce(fn, delay){
var timer = null;
@ -332,6 +381,53 @@
applyAll();
});
// Compare selected action
if (cmpSelBtn) cmpSelBtn.addEventListener('click', function(){
try {
if (selected.size !== 2) return;
var arr = Array.from(selected);
var url = '/decks/compare?A=' + encodeURIComponent(arr[0]) + '&B=' + encodeURIComponent(arr[1]);
window.location.href = url;
} catch(_){ }
});
// Latest two (by modified time across all decks, not just visible)
if (cmpLatestBtn) cmpLatestBtn.addEventListener('click', function(){
try {
// Gather all panels (including hidden) and sort by data-mtime desc
var rows = Array.prototype.slice.call(list.querySelectorAll('.panel'));
rows.sort(function(a,b){
var am = parseFloat(a.dataset.mtime || '0');
var bm = parseFloat(b.dataset.mtime || '0');
return bm - am;
});
// Take first two distinct names
var pick = [];
for (var i=0; i<rows.length && pick.length<2; i++){
var nm = rows[i].dataset.name || '';
if (nm && pick.indexOf(nm) === -1) pick.push(nm);
}
if (pick.length === 2){
var url = '/decks/compare?A=' + encodeURIComponent(pick[0]) + '&B=' + encodeURIComponent(pick[1]);
window.location.href = url;
} else {
if (window.toast) window.toast('Need at least two decks');
}
} catch(_){ }
});
// Open permalink prompt
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
try{
var token = prompt('Paste a /build/from?state=... URL or token:');
if(!token) return;
var m = token.match(/state=([^&]+)/);
var t = m ? m[1] : token.trim();
if(!t) return;
window.location.href = '/build/from?state=' + encodeURIComponent(t);
}catch(_){ }
});
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
// Clear UI state
try {
@ -408,6 +504,10 @@
// React to external hash changes
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
// Re-attach selection handlers when list changes order
var observer = new MutationObserver(function(){ attachSelectHandlers(); });
try { observer.observe(list, { childList: true }); } catch(_){ }
// Open deck: keyboard and mouse helpers on panels
function getPanelUrl(p){
try {
@ -551,5 +651,6 @@
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
#deck-list .panel.selected { box-shadow: 0 0 0 2px #10b981 inset; border-color: #10b981; }
</style>
{% endblock %}

View file

@ -2,6 +2,9 @@
{% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %}
<h2>Finished Deck</h2>
{% 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">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
@ -24,6 +27,7 @@
<button type="submit">Download TXT</button>
</form>
{% endif %}
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
<form method="get" action="/decks" style="display:inline; margin:0;">
<button type="submit">Back to Finished Decks</button>
</form>
@ -54,7 +58,7 @@
</div>
</div>
{% endif %}
{% include "partials/deck_summary.html" %}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% else %}
<div class="muted">No summary available.</div>
{% endif %}

View file

@ -7,6 +7,15 @@
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
</div>
<div class="card" style="background:#0f1115; 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>
<div style="display:flex; gap:1rem; flex-wrap:wrap">
<div><strong>Scroll FPS:</strong> <span id="perf-fps"></span></div>
<div><strong>Visible tiles:</strong> <span id="perf-visible"></span></div>
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div>
</div>
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
<h3 style="margin-top:0">Error triggers</h3>
<div class="row" style="display:flex; gap:.5rem; align-items:center">
@ -39,6 +48,40 @@
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
// Perf probe: listen to scroll on a card grid if present
try{
var fpsEl = document.getElementById('perf-fps');
var visEl = document.getElementById('perf-visible');
var rcEl = document.getElementById('perf-renders');
var grid = document.querySelector('.card-grid');
var last = performance.now();
var frames = 0; var renders = 0;
function tick(){
frames++;
var now = performance.now();
if (now - last >= 500){
var fps = Math.round((frames * 1000) / (now - last));
if (fpsEl) fpsEl.textContent = String(fps);
frames = 0; last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function updateVisible(){
try{
if (!grid) return;
var tiles = grid.querySelectorAll('.card-tile');
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
if (visEl) visEl.textContent = String(c);
}catch(_){ }
}
if (grid){
grid.addEventListener('scroll', updateVisible);
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
mo.observe(grid, { childList: true, subtree: true, attributes: false });
updateVisible();
}
}catch(_){ }
})();
</script>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Diagnostics: Synthetic Perf Probe</h2>
<p class="muted">Scroll the list; we estimate FPS and count re-renders. This page is only available when diagnostics are enabled.</p>
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin:.5rem 0 1rem 0;">
<div><strong>FPS:</strong> <span id="fps"></span></div>
<div><strong>Visible rows:</strong> <span id="rows"></span></div>
<div><strong>Render count:</strong> <span id="renders">0</span></div>
</div>
<div id="probe" style="height:60vh; overflow:auto; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
<ul id="list" style="list-style:none; margin:0; padding:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:8px 12px;">
{% for i in range(1,1201) %}
<li style="padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<div style="display:flex; align-items:center; gap:.5rem;">
<div style="width:64px; height:40px; background:#111; border:1px solid var(--border); border-radius:6px;">
<img class="card-thumb" alt="Thumb {{ i }}" loading="lazy" decoding="async" data-lqip
src="https://api.scryfall.com/cards/named?fuzzy=Lightning%20Bolt&format=image&version=small"
width="64" height="40" style="width:64px; height:40px; object-fit:cover; border-radius:6px;" />
</div>
<div style="display:flex; flex-direction:column; gap:.25rem;">
<strong>Row {{ i }}</strong>
<small class="muted">Synthetic item for performance testing</small>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</section>
<script>
(function(){
var probe = document.getElementById('probe');
var list = document.getElementById('list');
var fpsEl = document.getElementById('fps');
var rowsEl = document.getElementById('rows');
var rcEl = document.getElementById('renders');
var last = performance.now();
var frames = 0; var renders = 0;
function raf(){
frames++;
var now = performance.now();
if (now - last >= 500){
var fps = Math.round((frames * 1000) / (now - last));
if (fpsEl) fpsEl.textContent = String(fps);
frames = 0; last = now;
}
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
function updateVisible(){
if (!probe || !list) return;
var count = 0;
list.querySelectorAll('li').forEach(function(li){
// rough: count if within viewport
var rect = li.getBoundingClientRect();
var pRect = probe.getBoundingClientRect();
if (rect.bottom >= pRect.top && rect.top <= pRect.bottom) count++;
});
if (rowsEl) rowsEl.textContent = String(count);
}
if (probe){
probe.addEventListener('scroll', updateVisible);
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
mo.observe(list, { childList: true, subtree: true });
updateVisible();
}
})();
</script>
{% endblock %}

View file

@ -70,7 +70,7 @@
{% 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;">
<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;">
{% for n in names %}
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
@ -81,7 +81,9 @@
<label class="owned-row" style="cursor:pointer;" tabindex="0">
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
<div class="owned-vstack">
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
<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" />
<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">

View file

@ -81,7 +81,9 @@
{% 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)) %}
<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 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|join(', ')) if c.tags else '' }}"
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>
<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>
@ -539,7 +541,7 @@
}
function highlightNames(names, on){
if (!Array.isArray(names) || names.length === 0) return;
// List view spans
// List view spans
try {
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
@ -550,7 +552,7 @@
if (!on && !match) it.classList.remove('chart-highlight');
});
} catch(_) {}
// Thumbs view images
// Thumbs view images
try {
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
@ -561,6 +563,12 @@
if (!on && !match) tile.classList.remove('chart-highlight');
});
} catch(_) {}
// If virtualized lists are enabled, auto-scroll the Step 5 grid to the first match
try {
if (on && window.scrollCardIntoView && Array.isArray(names) && names.length) {
window.scrollCardIntoView(names[0]);
}
} catch(_) {}
}
attach();
document.addEventListener('htmx:afterSwap', function() { attach(); });