feat: polish precon picker — card art, color pips, grid layout

- Add full CSS for precon grid: responsive card grid, hover effects,
  install button states, color pips, dark theme styling
- Build commander art_crop image URLs from set_code/number during
  precon download (Scryfall direct image redirect, no API call needed)
- Batch-fetch color_identity from Scryfall /cards/collection endpoint
  (2 requests for ~145 unique commanders) and store in precon index
- Widen precon modal to 900px for grid layout
- Auto-invalidate precon cache when enrichment fields are missing
- JS renderer now shows commander art crop + card body layout
- Bump cache-busting version to v=4
This commit is contained in:
rgriffith committed 2026-03-19 10:10:40 +00:00
1 parent ef7bde841f
commit 310a408509
4 files changed
+264 -12

No files matched your search

+75 -1
View File
@@ -21,6 +21,7 @@ GITHUB_PRECON_URL = (
"master/decks_v2.json"
)
PRECON_CACHE_HOURS = 168
_SCRYFALL_COLLECTION_URL = "https://api.scryfall.com/cards/collection"
def _get_precon_dir() -> Path:
@@ -96,12 +97,16 @@ def download_precon_database(force: bool = False) -> dict:
with open(idx_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if len(existing) > 50:
# Invalidate cache if missing enrichment fields (imageUrl, colors)
has_enrichment = existing[0].get("imageUrl") or existing[0].get("colors")
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
if age_hours < PRECON_CACHE_HOURS:
if has_enrichment and age_hours < PRECON_CACHE_HOURS:
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
PRECON_INDEX.clear()
PRECON_INDEX.extend(existing)
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
if not has_enrichment:
log.info(" Precons: Cache missing enrichment data, re-downloading...")
except Exception:
pass
log.info(" Precons: Downloading full precon database from GitHub...")
@@ -135,6 +140,14 @@ def download_precon_database(force: bool = False) -> dict:
dck_path = precon_dir / file_name
commanders = deck.get('commander', [])
cmdr_names = [c['name'] for c in commanders] if commanders else []
# Build Scryfall art_crop image URL from first commander's set/number
image_url = ""
if commanders:
c0 = commanders[0]
sc = (c0.get('set_code') or '').lower()
num = c0.get('number', '')
if sc and num:
image_url = f"https://api.scryfall.com/cards/{sc}/{num}?format=image&version=art_crop"
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
with open(dck_path, "w", encoding="utf-8") as f:
f.write(_deck_to_dck(deck))
@@ -147,10 +160,71 @@ def download_precon_database(force: bool = False) -> dict:
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
"year": year, "releaseDate": release, "theme": "",
"fileName": file_name, "cardCount": total_cards,
"imageUrl": image_url,
})
# Enrich with color identity from Scryfall (best-effort)
_enrich_colors(index)
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, ensure_ascii=False)
PRECON_INDEX.clear()
PRECON_INDEX.extend(index)
log.info(f" Precons: {written} .dck files written, index saved")
return {"downloaded": written, "skipped": False, "total": written, "error": None}
def _enrich_colors(index: list[dict]) -> None:
"""Batch-fetch color_identity for commanders via Scryfall /cards/collection.
Scryfall accepts up to 75 identifiers per request. We look up each unique
commander name once, then propagate the result to every index entry.
"""
# Build unique commander -> identifiers map
cmdr_lookup: dict[str, dict] = {} # name -> {"name": ...}
for entry in index:
name = entry.get("commander", "")
if name and name != "Unknown" and name not in cmdr_lookup:
cmdr_lookup[name] = {"name": name}
if not cmdr_lookup:
return
names = list(cmdr_lookup.keys())
color_map: dict[str, list[str]] = {} # commander name -> ["W","U",...]
# Batch in chunks of 75
for i in range(0, len(names), 75):
batch = [{"name": n} for n in names[i:i + 75]]
try:
payload = json.dumps({"identifiers": batch}).encode("utf-8")
req = Request(
_SCRYFALL_COLLECTION_URL,
data=payload,
headers={
"Content-Type": "application/json",
"User-Agent": "CommanderAILab/3.0",
},
method="POST",
)
with urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
for card in result.get("data", []):
cname = card.get("name", "")
ci = card.get("color_identity", [])
if cname and ci:
color_map[cname] = ci
# Handle double-faced names: "A // B" -> also store "A"
if " // " in cname:
front = cname.split(" // ")[0]
color_map[front] = ci
# Respect Scryfall rate limit (100ms between requests)
time.sleep(0.15)
except Exception as e:
log.warning(f" Precons: Scryfall color lookup failed for batch {i}: {e}")
# Apply colors to index entries
enriched = 0
for entry in index:
name = entry.get("commander", "")
if name in color_map:
entry["colors"] = color_map[name]
enriched += 1
log.info(f" Precons: Enriched {enriched}/{len(index)} decks with color identity")
+169
View File
@@ -465,6 +465,175 @@
box-shadow: var(--shadow-sm);
}
/* ═══════════════════════════════════════════════════════════
Precon Picker
═══════════════════════════════════════════════════════════ */
.lab-modal-content.lab-modal-precon {
max-width: 900px;
width: 94%;
max-height: 82vh;
}
.lab-precon-controls {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
align-items: center;
}
.lab-precon-controls input {
flex: 1;
background: var(--surface-1);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3);
font-size: 13px;
}
.lab-precon-controls input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.lab-precon-install-all-btn,
.lab-precon-refresh-btn {
background: var(--surface-2);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: all var(--duration) var(--ease);
}
.lab-precon-install-all-btn:hover,
.lab-precon-refresh-btn:hover {
background: var(--surface-3);
color: var(--text);
border-color: var(--accent);
}
.lab-precon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--sp-3);
max-height: 56vh;
overflow-y: auto;
padding-right: var(--sp-1);
}
.lab-precon-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: all var(--duration) var(--ease);
display: flex;
flex-direction: column;
}
.lab-precon-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.lab-precon-card-installed {
border-color: var(--success);
opacity: 0.7;
}
.lab-precon-card-installed:hover {
opacity: 1;
}
.precon-card-art {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
display: block;
background: var(--surface-2);
}
.precon-card-body {
padding: var(--sp-2) var(--sp-3) var(--sp-3);
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.precon-card-colors {
display: flex;
gap: 3px;
margin-bottom: 2px;
}
.precon-card-name {
font-size: 13px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.precon-card-commander {
font-size: 11px;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.precon-card-set {
font-size: 11px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.precon-card-theme {
font-size: 10px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lab-precon-install-btn {
margin-top: auto;
padding: var(--sp-1) var(--sp-2);
background: var(--accent-dim);
color: var(--accent);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all var(--duration) var(--ease);
}
.lab-precon-install-btn:hover {
background: var(--accent);
color: var(--text-inverse);
}
.lab-precon-card-installed .lab-precon-install-btn {
background: var(--success-dim);
color: var(--success);
}
.lab-arch-badge {
font-size: 10px;
padding: 1px 7px;
+18 -9
View File
@@ -316,7 +316,7 @@ const AiLab = (() => {
<!-- v3: Precon Picker Modal -->
<div class="lab-modal-overlay" id="lab-precon-modal">
<div class="lab-modal-content lab-modal-wide">
<div class="lab-modal-content lab-modal-precon">
<div class="lab-modal-header">
<h3>Precon Decks — Official Commander Precons</h3>
<button class="lab-modal-close" onclick="AiLab.closePreconPicker()">✕</button>
@@ -796,16 +796,25 @@ const AiLab = (() => {
const installLabel = isInstalled ? 'Installed' : 'Install & Use';
const installClass = isInstalled ? 'lab-precon-card-installed' : '';
// Commander art from Scryfall — use imageUrl if backend provided it,
// otherwise build a fuzzy lookup URL from the commander name
const artUrl = p.imageUrl
|| `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
return `
<div class="lab-precon-card ${installClass}" onclick="AiLab.installPrecon('${escapeAttr(p.fileName)}')">
<div class="precon-card-colors">${colors}</div>
<div class="precon-card-name">${escapeHtml(p.name)}</div>
<div class="precon-card-commander">${escapeHtml(p.commander)}</div>
<div class="precon-card-set">${escapeHtml(p.set)} (${p.year})</div>
<div class="precon-card-theme">${escapeHtml(p.theme || '')}</div>
<button class="lab-precon-install-btn" title="Install to Forge and add to deck list">
${installLabel}
</button>
<img class="precon-card-art" src="${artUrl}" alt="${escapeAttr(p.commander)}" loading="lazy"
onerror="this.style.display='none'" />
<div class="precon-card-body">
<div class="precon-card-colors">${colors}</div>
<div class="precon-card-name">${escapeHtml(p.name)}</div>
<div class="precon-card-commander">${escapeHtml(p.commander)}</div>
<div class="precon-card-set">${escapeHtml(p.set)} (${p.year})</div>
${p.theme ? `<div class="precon-card-theme">${escapeHtml(p.theme)}</div>` : ''}
<button class="lab-precon-install-btn" title="Install to Forge and add to deck list">
${installLabel}
</button>
</div>
</div>`;
}).join('');
}
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="ai-lab.css?v=3" />
<link rel="stylesheet" href="ai-lab.css?v=4" />
<style>
/* ── Dashboard Feature Grid ──────────────────── */
.feature-grid {
@@ -196,6 +196,6 @@
</div>
</div>
<script src="ai-lab.js?v=3"></script>
<script src="ai-lab.js?v=4"></script>
</body>
</html>