From 310a4085095c3478dec51322eca76fb8bf32a142 Mon Sep 17 00:00:00 2001 From: rgriffith Date: Thu, 19 Mar 2026 10:10:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20polish=20precon=20picker=20=E2=80=94=20?= =?UTF-8?q?card=20art,=20color=20pips,=20grid=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- services/precon_service.py | 76 ++++++++++++++++- ui/ai-lab.css | 169 +++++++++++++++++++++++++++++++++++++ ui/ai-lab.js | 27 ++++-- ui/index.html | 4 +- 4 files changed, 264 insertions(+), 12 deletions(-) diff --git a/services/precon_service.py b/services/precon_service.py index 909ef64..05ec60e 100644 --- a/services/precon_service.py +++ b/services/precon_service.py @@ -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") diff --git a/ui/ai-lab.css b/ui/ai-lab.css index b4e8a38..4fc20b3 100644 --- a/ui/ai-lab.css +++ b/ui/ai-lab.css @@ -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; diff --git a/ui/ai-lab.js b/ui/ai-lab.js index 0621134..718bb76 100644 --- a/ui/ai-lab.js +++ b/ui/ai-lab.js @@ -316,7 +316,7 @@ const AiLab = (() => {
-
+

Precon Decks — Official Commander Precons

@@ -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 `
-
${colors}
-
${escapeHtml(p.name)}
-
${escapeHtml(p.commander)}
-
${escapeHtml(p.set)} (${p.year})
-
${escapeHtml(p.theme || '')}
- + ${escapeAttr(p.commander)} +
+
${colors}
+
${escapeHtml(p.name)}
+
${escapeHtml(p.commander)}
+
${escapeHtml(p.set)} (${p.year})
+ ${p.theme ? `
${escapeHtml(p.theme)}
` : ''} + +
`; }).join(''); } diff --git a/ui/index.html b/ui/index.html index 76a93c5..2eb253c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,7 +8,7 @@ - +