diff --git a/lan-client/ai-bridge.js b/lan-client/ai-bridge.js
index c42b684..e11d663 100644
--- a/lan-client/ai-bridge.js
+++ b/lan-client/ai-bridge.js
@@ -366,7 +366,11 @@ class GameStateBridge {
seat_type: p.isAI
? this._mapAiProfile(gameState.aiDifficulty)
: 'Human',
- deck_name: p.commanderCardName || '',
+ deck_name: p.deckFileName || (
+ p.commanderCardName
+ ? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
+ : ''
+ ),
player_name: p.name,
}));
@@ -394,11 +398,13 @@ class GameStateBridge {
*/
_mapAiProfile(difficulty) {
const map = {
- easy: 'AI',
- normal: 'AI Aggro',
- hard: 'AI Control',
+ easy: 'AI',
+ normal: 'AI Midrange',
+ hard: 'AI Control',
+ combo: 'AI Combo',
+ aggro: 'AI Aggro',
};
- return map[difficulty] || 'AI';
+ return map[difficulty] || 'AI Midrange';
}
// ── Game State Serialization ──────────────────────────────
@@ -451,7 +457,10 @@ class GameStateBridge {
// Zone contents — card names
hand: p.zones.hand.map(c => c.name || 'Unknown'),
hand_count: p.zones.hand.length,
- battlefield: bfCards.map(c => ({
+ // Flat string list for the state encoder
+ battlefield: bfCards.map(c => c.name || 'Unknown'),
+ // Structured data for backend logic that needs detail
+ battlefield_detail: bfCards.map(c => ({
name: c.name || 'Unknown',
type: c.typeLine || '',
power: c.power || '0',
@@ -488,7 +497,7 @@ class GameStateBridge {
return {
game_id: this._sessionGameId || '',
- turn: gameState.turnCounter || 1,
+ turn: gameState.turnNumber || gameState.turnCounter || 1,
phase: phaseMap[phaseId] || 'main_1',
active_player: playerIdx,
players: players,
diff --git a/lan-client/collection.css b/lan-client/collection.css
new file mode 100644
index 0000000..273082b
--- /dev/null
+++ b/lan-client/collection.css
@@ -0,0 +1,3234 @@
+/* ── JS-referenced aliases (collection.js uses these in template literals) ── */
+:root {
+ --lab-purple: var(--purple);
+ --lab-warning: var(--warning);
+}
+
+/* ═══════════════════════════════════════════════════════════
+ Commander AI Lab — Collection Management Styles
+ Uses coll- prefix to avoid conflicts with lab- classes
+ Design tokens inherited from ai-lab.css :root variables
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── Z-INDEX STACK ────────────────────────────────────────
+ 10 table thead (sticky)
+ 50 drawer header (sticky inside drawer)
+ 100 topbar (sticky)
+ 200 drawer backdrop
+ 250 drawer panel
+ 300 category popover
+ 1000 modal overlays (import/export)
+ ──────────────────────────────────────────────────────── */
+
+/* ── 1. Page Layout ──────────────────────────────────────── */
+
+#collection-page {
+ min-height: 100vh;
+ background: var(--bg);
+ padding: 0;
+ font-family: var(--font);
+ color: var(--text);
+ position: relative;
+ isolation: isolate;
+}
+
+.coll-topbar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: var(--nav-height);
+ background: rgba(12, 14, 20, 0.95);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--border);
+ z-index: 100;
+ padding: 12px 24px;
+ gap: 12px;
+}
+
+.coll-topbar-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.coll-topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.coll-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ line-height: 1;
+}
+
+.coll-count {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.coll-back-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 13px;
+ cursor: pointer;
+ padding: 6px 10px;
+ border-radius: var(--radius-md);
+ transition: color 0.15s;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.coll-back-btn:hover {
+ color: var(--accent);
+}
+
+.coll-search {
+ width: 240px;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px 14px;
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.coll-search::placeholder {
+ color: var(--text-secondary);
+}
+
+.coll-search:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.coll-scan-btn,
+.coll-import-btn,
+.coll-export-btn,
+.coll-autoclassify-btn,
+.coll-reenrich-btn {
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 7px 14px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.coll-export-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Scan button has blue/accent styling */
+.coll-scan-btn {
+ background: linear-gradient(135deg, rgba(91, 158, 240, 0.12), rgba(91, 158, 240, 0.18));
+ border-color: rgba(91, 158, 240, 0.4);
+ color: var(--accent);
+}
+
+.coll-scan-btn:hover {
+ background: linear-gradient(135deg, rgba(91, 158, 240, 0.22), rgba(91, 158, 240, 0.28));
+ border-color: var(--accent);
+ color: var(--accent-hover);
+}
+
+/* Import button has green accent like lab-import-submit-btn */
+.coll-import-btn {
+ background: linear-gradient(135deg, rgba(35, 134, 54, 0.15), rgba(46, 160, 67, 0.15));
+ border-color: rgba(46, 160, 67, 0.4);
+ color: var(--success);
+}
+
+.coll-import-btn:hover {
+ background: linear-gradient(135deg, rgba(35, 134, 54, 0.25), rgba(46, 160, 67, 0.25));
+ border-color: var(--success);
+ color: var(--success);
+}
+
+/* Auto-Classify button — amber/orange accent */
+.coll-autoclassify-btn {
+ background: linear-gradient(135deg, rgba(227, 155, 35, 0.12), rgba(227, 155, 35, 0.18));
+ border-color: rgba(227, 155, 35, 0.4);
+ color: var(--warning);
+}
+
+.coll-autoclassify-btn:hover {
+ background: linear-gradient(135deg, rgba(227, 155, 35, 0.22), rgba(227, 155, 35, 0.28));
+ border-color: var(--warning);
+ color: var(--gold);
+}
+
+.coll-autoclassify-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.coll-reenrich-btn {
+ background: linear-gradient(135deg, rgba(136, 98, 235, 0.12), rgba(136, 98, 235, 0.18));
+ border-color: rgba(136, 98, 235, 0.4);
+ color: var(--purple);
+}
+
+.coll-reenrich-btn:hover {
+ background: linear-gradient(135deg, rgba(136, 98, 235, 0.22), rgba(136, 98, 235, 0.28));
+ border-color: var(--purple);
+ color: var(--purple);
+}
+
+.coll-reenrich-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* ── Nav Links ──────────────────────────────────────────── */
+
+.coll-nav-link {
+ font-size: 13px;
+ text-decoration: none;
+ padding: 7px 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: var(--surface-1);
+ transition: border-color 0.15s, background 0.15s;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.coll-nav-link:hover {
+ background: rgba(91, 158, 240, 0.06);
+}
+
+/* ── 2. Main Content ─────────────────────────────────────── */
+
+.coll-main {
+ display: flex;
+ gap: 20px;
+ padding: 16px 24px;
+ align-items: flex-start;
+}
+
+/* Table wrapper — fills remaining space */
+.coll-table-wrap {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ /* Constrain height so table scrolls independently of page */
+ max-height: calc(100vh - var(--nav-height) - 80px);
+ overflow: hidden;
+}
+
+/* ── 3. Filter Panel ─────────────────────────────────────── */
+
+.coll-filters {
+ width: 240px;
+ flex-shrink: 0;
+ max-height: calc(100vh - var(--nav-height) - 80px);
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.coll-filters::-webkit-scrollbar {
+ width: 6px;
+}
+
+.coll-filters::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.coll-filters::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+.coll-filters::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+.coll-filter-section {
+ margin-bottom: 16px;
+}
+
+.coll-filter-label {
+ display: block;
+ text-transform: uppercase;
+ font-size: 11px;
+ color: var(--text-secondary);
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+/* Color toggles */
+.coll-color-btns {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.coll-color-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ transition: border-color 0.15s, transform 0.15s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+ background: var(--surface-1);
+ color: var(--text-secondary);
+ padding: 0;
+}
+
+.coll-color-btn:hover {
+ border-color: rgba(255, 255, 255, 0.5);
+ transform: scale(1.05);
+}
+
+.coll-color-btn.active {
+ border-color: #fff;
+ transform: scale(1.1);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
+}
+
+/* Type list checkboxes */
+.coll-type-checks {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.coll-check-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--text);
+ user-select: none;
+ padding: 2px 0;
+}
+
+.coll-check-label input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ background: var(--bg);
+ cursor: pointer;
+ flex-shrink: 0;
+ position: relative;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.coll-check-label input[type="checkbox"]:checked {
+ background: var(--accent);
+ border-color: var(--accent);
+}
+
+.coll-check-label input[type="checkbox"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 3px;
+ top: 1px;
+ width: 5px;
+ height: 8px;
+ border: 2px solid #000;
+ border-top: none;
+ border-left: none;
+ transform: rotate(45deg);
+}
+
+.coll-check-label:hover {
+ color: var(--text);
+}
+
+.coll-check-label:hover input[type="checkbox"] {
+ border-color: var(--accent);
+}
+
+/* Toggle buttons */
+.coll-toggle-btns {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.coll-toggle-btn {
+ padding: 5px 12px;
+ border-radius: 14px;
+ font-size: 11px;
+ cursor: pointer;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+ user-select: none;
+}
+
+.coll-toggle-btn:hover {
+ border-color: var(--text-secondary);
+ color: var(--text);
+}
+
+.coll-toggle-btn.active {
+ background: rgba(91, 158, 240, 0.15);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Finish radios */
+.coll-finish-radios {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+/* Range inputs */
+.coll-range-inputs {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.coll-range-sep {
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.coll-range-input {
+ width: 70px;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px;
+ font-size: 12px;
+ text-align: center;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.coll-range-input:focus {
+ border-color: var(--accent);
+}
+
+/* Category filter chips */
+.coll-cat-filter-btns {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.coll-cat-filter-btn {
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ cursor: pointer;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+ user-select: none;
+}
+
+.coll-cat-filter-btn:hover {
+ border-color: var(--text-secondary);
+ color: var(--text);
+}
+
+.coll-cat-filter-btn.active {
+ background: rgba(91, 158, 240, 0.15);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Radio labels (shared by filters and import) */
+.coll-radio-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text);
+ user-select: none;
+ padding: 3px 0;
+}
+
+.coll-radio-label input[type="radio"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--border);
+ border-radius: 50%;
+ background: var(--bg);
+ cursor: pointer;
+ position: relative;
+ transition: border-color 0.15s;
+ flex-shrink: 0;
+}
+
+.coll-radio-label input[type="radio"]:checked {
+ border-color: var(--accent);
+}
+
+.coll-radio-label input[type="radio"]:checked::after {
+ content: '';
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ background: var(--accent);
+ border-radius: 50%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+/* Reset button */
+.coll-reset-btn {
+ width: 100%;
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-md);
+ padding: 7px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+ margin-top: 4px;
+}
+
+.coll-reset-btn:hover {
+ border-color: var(--error);
+ color: var(--error);
+}
+
+/* ── 3b. New Filters ────────────────────────────────────────── */
+
+/* Rarity buttons */
+.coll-rarity-btns {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.coll-rarity-btn {
+ width: 36px;
+ height: 30px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.coll-rarity-btn:hover {
+ border-color: var(--text-secondary);
+}
+
+.coll-rarity-btn.active.coll-rarity-common {
+ background: var(--surface-0);
+ border-color: var(--text-secondary);
+ color: var(--text);
+}
+
+.coll-rarity-btn.active.coll-rarity-uncommon {
+ background: var(--success-dim);
+ border-color: var(--success);
+ color: var(--success);
+}
+
+.coll-rarity-btn.active.coll-rarity-rare {
+ background: var(--warning-dim);
+ border-color: var(--warning);
+ color: var(--gold);
+}
+
+.coll-rarity-btn.active.coll-rarity-mythic {
+ background: var(--error-dim);
+ border-color: var(--error);
+ color: var(--error);
+}
+
+/* Power / Toughness range row */
+.coll-range-row {
+ display: flex;
+ gap: 10px;
+}
+
+.coll-range-group {
+ flex: 1;
+}
+
+.coll-range-sublabel {
+ display: block;
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-bottom: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Set filter autocomplete */
+.coll-set-filter,
+.coll-kw-filter {
+ position: relative;
+}
+
+.coll-set-input,
+.coll-kw-input {
+ width: 100%;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px 8px;
+ font-size: 12px;
+ color: var(--text);
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.coll-set-input:focus,
+.coll-kw-input:focus {
+ border-color: var(--accent);
+}
+
+.coll-set-dropdown,
+.coll-kw-dropdown {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 180px;
+ overflow-y: auto;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
+ z-index: 50;
+ box-shadow: 0 4px 12px rgba(0,0,0,.4);
+}
+
+.coll-set-dd-item,
+.coll-kw-dd-item {
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text);
+ cursor: pointer;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.coll-set-dd-item:hover,
+.coll-kw-dd-item:hover {
+ background: rgba(91, 158, 240, 0.1);
+}
+
+.coll-set-dd-code {
+ font-weight: 600;
+ color: var(--accent);
+ min-width: 40px;
+}
+
+.coll-set-dd-name {
+ color: var(--text-secondary);
+}
+
+/* Set/Keyword chips */
+.coll-set-chips,
+.coll-kw-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.coll-set-chip,
+.coll-kw-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(91, 158, 240, 0.12);
+ border: 1px solid rgba(91, 158, 240, 0.25);
+ border-radius: 12px;
+ padding: 2px 8px;
+ font-size: 11px;
+ color: var(--accent);
+}
+
+.coll-set-chip button,
+.coll-kw-chip button {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+}
+
+.coll-set-chip button:hover,
+.coll-kw-chip button:hover {
+ color: var(--error);
+}
+
+/* ── 4. Table ─────────────────────────────────────────────── */
+
+.coll-table-container {
+ flex: 1;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: auto;
+}
+
+.coll-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: 13px;
+}
+
+.coll-table thead {
+ position: sticky;
+ top: 0;
+ background: var(--surface-1);
+ z-index: 10;
+}
+
+.coll-table th,
+.coll-th {
+ padding: 10px 12px;
+ text-align: left;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+ border-bottom: 2px solid var(--border);
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.coll-th-sortable {
+ cursor: pointer;
+ user-select: none;
+ transition: color 0.15s, background 0.15s;
+ position: relative;
+}
+
+.coll-th-sortable:hover {
+ color: var(--accent);
+ background: rgba(255,255,255,0.06);
+}
+
+.coll-th-active {
+ color: var(--accent);
+ background: rgba(255,255,255,0.04);
+}
+
+.coll-table td,
+.coll-td {
+ padding: 8px 12px;
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
+ font-size: 13px;
+ color: var(--text);
+ vertical-align: middle;
+}
+
+.coll-table tbody tr,
+.coll-row {
+ cursor: pointer;
+ transition: background 0.1s;
+ animation: coll-fade-in 0.15s ease;
+}
+
+.coll-table tbody tr:hover,
+.coll-row:hover {
+ background: var(--surface-1);
+}
+
+.coll-table tbody tr.selected {
+ background: rgba(91, 158, 240, 0.08);
+ box-shadow: inset 3px 0 0 var(--accent);
+}
+
+/* Cell-specific styles */
+.coll-card-name,
+.coll-td-name {
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+}
+
+.coll-td-type {
+ color: var(--text-secondary);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.coll-td-oracle {
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.coll-td-price {
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.coll-td-salt {
+ font-weight: 600;
+ text-align: right;
+}
+
+.coll-td-quantity {
+ text-align: center;
+ font-weight: 600;
+}
+
+.coll-td-cmc {
+ text-align: center;
+}
+
+.coll-td-gc {
+ text-align: center;
+}
+
+.coll-td-mana {
+ white-space: nowrap;
+}
+
+.coll-td-pt {
+ text-align: center;
+ white-space: nowrap;
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ font-size: 12px;
+}
+
+.coll-td-rarity {
+ text-align: center;
+ font-size: 12px;
+ text-transform: capitalize;
+}
+
+.coll-td-set {
+ color: var(--text-secondary);
+ font-size: 12px;
+ max-width: 150px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.coll-td-edhrec {
+ text-align: right;
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* Rarity colors */
+.coll-rarity-common { color: var(--text-secondary); }
+.coll-rarity-uncommon { color: var(--accent); }
+.coll-rarity-rare { color: var(--gold); }
+.coll-rarity-mythic { color: var(--warning); }
+.coll-rarity-special { color: var(--purple); }
+.coll-rarity-bonus { color: var(--purple); }
+
+/* Salt color classes */
+.salt-green { color: var(--success); }
+.salt-yellow { color: var(--warning); }
+.salt-orange { color: var(--warning); }
+.salt-red { color: var(--error); }
+
+/* Mana pips */
+.coll-mana-pip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.coll-colorless-pip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ font-size: 9px;
+ font-weight: 700;
+ background: var(--text-tertiary);
+ color: #fff;
+}
+
+/* Category chips in table cell */
+.coll-cat-chip {
+ display: inline-block;
+ padding: 1px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+ margin: 1px 2px;
+}
+
+.coll-cat-chip:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.coll-no-category {
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+/* Finish badges */
+.coll-finish-badge {
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ white-space: nowrap;
+ display: inline-block;
+}
+
+.finish-normal,
+.coll-finish-normal {
+ background: rgba(139, 148, 158, 0.15);
+ color: var(--text-secondary);
+}
+
+.finish-foil,
+.coll-finish-foil {
+ background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(163, 113, 247, 0.15));
+ color: var(--gold);
+ border: 1px solid rgba(255, 215, 0, 0.3);
+}
+
+.finish-etched,
+.coll-finish-etched {
+ background: rgba(91, 158, 240, 0.15);
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.3);
+}
+
+/* Icons */
+.coll-gc-star {
+ color: var(--gold);
+ font-size: 14px;
+}
+
+.coll-legendary-icon {
+ color: var(--gold);
+ font-size: 11px;
+ display: inline-block;
+}
+
+.coll-basic-icon {
+ color: var(--text-secondary);
+ font-size: 11px;
+ display: inline-block;
+}
+
+/* ── 5. Pagination ───────────────────────────────────────── */
+
+.coll-pagination {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 0;
+ border-top: 1px solid var(--border);
+ margin-top: 8px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.coll-page-info {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.coll-total-label {
+ color: var(--text-secondary);
+ font-size: 12px;
+ margin-left: 4px;
+}
+
+.coll-page-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.coll-page-btn {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px 14px;
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text);
+ transition: border-color 0.15s, color 0.15s;
+}
+
+.coll-page-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.coll-page-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.coll-page-size {
+ font-size: 13px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.coll-page-size select {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 4px 8px;
+ font-size: 12px;
+ color: var(--text);
+ cursor: pointer;
+ outline: none;
+}
+
+.coll-page-size select:focus {
+ border-color: var(--accent);
+}
+
+/* ── 6. Detail Drawer ────────────────────────────────────── */
+
+.coll-drawer-backdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 200;
+ transition: opacity 0.25s ease;
+ opacity: 0;
+}
+
+.coll-drawer-backdrop.open {
+ display: block;
+ opacity: 1;
+}
+
+.coll-drawer {
+ position: fixed;
+ right: 0;
+ top: 0;
+ width: 420px;
+ height: 100vh;
+ height: 100dvh;
+ background: var(--bg);
+ border-left: 1px solid var(--border);
+ z-index: 250;
+ transform: translateX(100%);
+ transition: transform 0.25s ease;
+ overflow-y: auto;
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.coll-drawer::-webkit-scrollbar {
+ width: 6px;
+}
+
+.coll-drawer::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.coll-drawer::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+.coll-drawer::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+.coll-drawer.open {
+ transform: translateX(0);
+}
+
+.coll-drawer-inner {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.coll-drawer-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ background: var(--bg);
+ z-index: 50;
+}
+
+.coll-drawer-header-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 6px;
+}
+
+.coll-drawer-card-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+ line-height: 1.3;
+}
+
+.coll-drawer-close {
+ background: none;
+ border: none;
+ font-size: 18px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: 4px;
+ transition: color 0.15s;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.coll-drawer-close:hover {
+ color: #fff;
+}
+
+.coll-drawer-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ flex-wrap: wrap;
+}
+
+.coll-drawer-type {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.coll-drawer-body {
+ padding: 20px;
+ flex: 1;
+}
+
+.coll-drawer-image-wrap {
+ text-align: center;
+ margin-bottom: 16px;
+}
+
+.coll-drawer-image {
+ max-width: 100%;
+ width: 280px;
+ border-radius: 12px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+}
+
+.coll-drawer-no-image {
+ width: 280px;
+ height: 200px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+/* Loading/error states inside drawer */
+.coll-drawer-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px;
+ color: var(--text-secondary);
+}
+
+.coll-drawer-error {
+ padding: 24px;
+ text-align: center;
+ color: var(--error);
+ font-size: 13px;
+}
+
+/* Drawer Tabs */
+.coll-drawer-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+ margin-top: 8px;
+}
+
+.coll-tab-btn {
+ padding: 10px 16px;
+ font-size: 13px;
+ cursor: pointer;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-secondary);
+ background: none;
+ transition: color 0.15s, border-color 0.15s;
+ user-select: none;
+ margin-bottom: -1px;
+}
+
+.coll-tab-btn:hover {
+ color: var(--text);
+}
+
+.coll-tab-btn.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.coll-drawer-tab-content {
+ min-height: 200px;
+}
+
+/* Overview tab */
+.coll-overview-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 16px;
+}
+
+.coll-overview-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(48, 54, 61, 0.3);
+ font-size: 13px;
+}
+
+.coll-ov-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.coll-oracle-text {
+ font-size: 13px;
+ color: var(--text);
+ padding: 12px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ line-height: 1.5;
+ margin-bottom: 16px;
+}
+
+/* Drawer sections */
+.coll-drawer-section {
+ margin-top: 16px;
+}
+
+.coll-drawer-section-label {
+ display: block;
+ font-size: 11px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+/* Finish select in drawer */
+.coll-finish-select {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--text);
+ cursor: pointer;
+ outline: none;
+ width: 100%;
+ transition: border-color 0.15s;
+}
+
+.coll-finish-select:focus {
+ border-color: var(--accent);
+}
+
+/* Category chips in drawer */
+.coll-drawer-cat-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.coll-drawer-cat-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ background: rgba(91, 158, 240, 0.12);
+ border: 1px solid rgba(91, 158, 240, 0.3);
+ color: var(--accent);
+}
+
+.coll-chip-remove {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1;
+ padding: 0 2px;
+ opacity: 0.6;
+ transition: opacity 0.15s;
+}
+
+.coll-chip-remove:hover {
+ opacity: 1;
+}
+
+.coll-cat-suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.coll-cat-suggest-btn {
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ background: var(--bg);
+ border: 1px dashed var(--border);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+
+.coll-cat-suggest-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.coll-cat-add-row,
+.coll-tag-add-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.coll-cat-input {
+ flex: 1;
+ min-width: 0;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 5px 10px;
+ font-size: 12px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.coll-cat-input:focus {
+ border-color: var(--accent);
+}
+
+.coll-cat-add-btn {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 5px 12px;
+ font-size: 12px;
+ color: var(--accent);
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ white-space: nowrap;
+}
+
+.coll-cat-add-btn:hover {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.1);
+}
+
+/* Tags in drawer */
+.coll-drawer-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.coll-drawer-tag-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ background: rgba(139, 148, 158, 0.12);
+ border: 1px solid rgba(139, 148, 158, 0.3);
+ color: var(--text);
+}
+
+/* Notes textarea */
+.coll-notes-textarea {
+ width: 100%;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 10px;
+ font-size: 13px;
+ resize: vertical;
+ min-height: 80px;
+ font-family: inherit;
+ line-height: 1.5;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ box-sizing: border-box;
+}
+
+.coll-notes-textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+/* Save row in drawer */
+.coll-drawer-save-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.coll-save-btn {
+ background: linear-gradient(135deg, var(--success), var(--success));
+ color: #fff;
+ font-weight: 600;
+ border: none;
+ border-radius: var(--radius-md);
+ padding: 8px 20px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: transform 0.15s, box-shadow 0.15s;
+}
+
+.coll-save-btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(46, 160, 67, 0.3);
+}
+
+.coll-save-status {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.coll-save-status.saved {
+ color: var(--success);
+}
+
+.coll-save-status.error {
+ color: var(--error);
+}
+
+/* EDHREC Tab */
+.coll-edhrec-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 24px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.coll-edhrec-prompt {
+ text-align: center;
+ padding: 24px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.coll-edhrec-fetch-btn {
+ background: var(--accent);
+ color: #000;
+ font-weight: 600;
+ border: none;
+ border-radius: var(--radius-md);
+ padding: 8px 20px;
+ font-size: 13px;
+ cursor: pointer;
+ margin-top: 8px;
+ transition: background 0.15s;
+}
+
+.coll-edhrec-fetch-btn:hover {
+ background: var(--accent-hover);
+}
+
+.coll-edhrec-recs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.coll-edhrec-rec {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ gap: 8px;
+}
+
+.coll-edhrec-rec:hover {
+ border-color: var(--accent);
+}
+
+.coll-edhrec-rec-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+}
+
+.coll-edhrec-rec-meta {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+.coll-edhrec-role {
+ color: var(--text-secondary);
+}
+
+.coll-edhrec-synergy {
+ color: var(--success);
+ font-weight: 600;
+}
+
+.coll-edhrec-inclusion {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.coll-edhrec-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--text-secondary);
+}
+
+.coll-edhrec-links {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+
+.coll-edhrec-link {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px 14px;
+ font-size: 12px;
+ color: var(--accent);
+ text-decoration: none;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.coll-edhrec-link:hover {
+ background: rgba(91, 158, 240, 0.1);
+ border-color: var(--accent);
+}
+
+/* Decks tab */
+.coll-decks-tab {
+ padding: 8px 0;
+}
+
+.coll-decks-hint {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ line-height: 1.5;
+}
+
+.coll-decks-links {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.coll-deck-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ color: var(--accent);
+ text-decoration: none;
+ font-size: 13px;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.coll-deck-link:hover {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.08);
+}
+
+/* ── 7. Modal Overlays (Import / Export) ─────────────────── */
+
+.coll-modal-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 1000;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+}
+
+.coll-modal-overlay.active {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.coll-modal-content {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 100%;
+ max-width: 500px;
+ max-height: 80vh;
+ overflow-y: auto;
+ padding: 24px;
+ animation: coll-slide-up 0.2s ease;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.coll-modal-content::-webkit-scrollbar {
+ width: 6px;
+}
+
+.coll-modal-content::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.coll-modal-content::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+.coll-modal-wide {
+ max-width: 640px;
+}
+
+.coll-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.coll-modal-header h2,
+.coll-modal-header h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+}
+
+.coll-modal-close {
+ background: none;
+ border: none;
+ font-size: 18px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: 4px;
+ transition: color 0.15s;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.coll-modal-close:hover {
+ color: #fff;
+}
+
+.coll-modal-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ gap: 8px;
+}
+
+/* ── 8. Import Modal Internals ───────────────────────────── */
+
+/* Step dots */
+.coll-import-steps {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ margin-bottom: 16px;
+}
+
+.coll-step-dot {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.coll-step-dot.active {
+ background: var(--accent);
+ color: #000;
+ border-color: var(--accent);
+}
+
+.coll-step-dot.done {
+ background: var(--success);
+ color: #000;
+ border-color: var(--success);
+}
+
+.coll-step-sep {
+ color: var(--border);
+ font-size: 12px;
+ padding: 0 2px;
+}
+
+/* Import step content */
+.coll-import-step-content {
+ min-height: 150px;
+}
+
+/* Step 1: Source list */
+.coll-source-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.coll-source-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.coll-source-option:hover {
+ border-color: var(--accent);
+}
+
+.coll-source-option.selected {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.08);
+}
+
+.coll-source-option input[type="radio"] {
+ flex-shrink: 0;
+}
+
+.coll-source-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.coll-source-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: #fff;
+}
+
+.coll-source-desc {
+ font-size: 11px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Step 2: Content / textarea */
+.coll-import-textarea-wrap,
+.coll-import-file-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.coll-form-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ display: block;
+}
+
+.coll-import-textarea {
+ width: 100%;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 12px;
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ resize: vertical;
+ min-height: 200px;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ box-sizing: border-box;
+}
+
+.coll-import-textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.coll-import-hint {
+ font-size: 11px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.coll-import-hint code {
+ background: var(--surface-1);
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+
+/* File drop zone */
+.coll-file-drop-zone {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius-md);
+ padding: 24px;
+ text-align: center;
+ cursor: pointer;
+ position: relative;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.coll-file-drop-zone:hover,
+.coll-file-drop-zone.drag-over {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.05);
+}
+
+.coll-file-drop-zone input[type="file"] {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.coll-file-drop-label {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.coll-file-ok {
+ color: var(--success);
+ font-weight: 600;
+}
+
+.coll-file-preview {
+ margin-top: 8px;
+}
+
+.coll-file-preview-label {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+
+.coll-file-preview-text {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px;
+ font-size: 11px;
+ color: var(--text);
+ overflow-x: auto;
+ white-space: pre;
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+ margin: 0;
+}
+
+/* Step 3: Mapping */
+.coll-mapping-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+}
+
+.coll-mapping-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.coll-mapping-table th {
+ padding: 8px;
+ font-size: 11px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+}
+
+.coll-mapping-table td {
+ padding: 6px 8px;
+ vertical-align: middle;
+}
+
+.coll-mapping-row td:first-child {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+}
+
+.coll-mapping-select {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 4px 8px;
+ font-size: 12px;
+ color: var(--text);
+ width: 100%;
+ outline: none;
+ transition: border-color 0.15s;
+ cursor: pointer;
+}
+
+.coll-mapping-select:focus {
+ border-color: var(--accent);
+}
+
+/* Step 4: Options */
+.coll-import-options {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.coll-form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.coll-mode-desc {
+ font-size: 11px;
+ color: var(--text-secondary);
+ display: block;
+ margin-top: 2px;
+}
+
+.coll-import-warn {
+ font-size: 13px;
+ color: var(--warning);
+ padding: 10px 14px;
+ background: rgba(210, 153, 34, 0.08);
+ border: 1px solid rgba(210, 153, 34, 0.3);
+ border-radius: var(--radius-md);
+ line-height: 1.5;
+}
+
+/* Import footer buttons */
+.coll-import-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+ gap: 8px;
+}
+
+.coll-btn-primary {
+ background: var(--accent);
+ color: #000;
+ font-weight: 600;
+ border: none;
+ border-radius: var(--radius-md);
+ padding: 8px 24px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.15s;
+}
+
+.coll-btn-primary:hover {
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+}
+
+.coll-btn-secondary {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 13px;
+ cursor: pointer;
+ padding: 8px 16px;
+ border-radius: var(--radius-md);
+ transition: color 0.15s;
+}
+
+.coll-btn-secondary:hover {
+ color: var(--text);
+}
+
+/* Import result / status */
+.coll-import-status {
+ margin-top: 12px;
+}
+
+.coll-import-progress {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ padding: 12px;
+}
+
+.coll-import-result {
+ padding: 16px;
+ text-align: center;
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ line-height: 1.6;
+ margin-bottom: 12px;
+}
+
+.coll-import-result.success {
+ color: var(--success);
+ background: rgba(46, 160, 67, 0.08);
+ border: 1px solid rgba(46, 160, 67, 0.3);
+}
+
+.coll-import-result.error {
+ color: var(--error);
+ background: rgba(248, 81, 73, 0.08);
+ border: 1px solid rgba(248, 81, 73, 0.3);
+}
+
+.coll-import-errors {
+ text-align: left;
+ margin-top: 12px;
+ padding: 10px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.coll-import-errors-label {
+ font-weight: 600;
+ margin-bottom: 6px;
+ color: var(--warning);
+}
+
+.coll-import-errors ul {
+ list-style: disc;
+ margin: 0;
+ padding-left: 16px;
+}
+
+.coll-import-errors li {
+ padding: 2px 0;
+}
+
+/* ── 9. Export Modal Internals ───────────────────────────── */
+
+.coll-export-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.coll-export-select {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px 12px;
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+ outline: none;
+ width: 100%;
+ transition: border-color 0.15s;
+}
+
+.coll-export-select:focus {
+ border-color: var(--accent);
+}
+
+.coll-export-info {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* ── 10. Category Popover ────────────────────────────────── */
+
+#coll-cat-popover {
+ position: absolute;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 12px;
+ z-index: 300;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ min-width: 220px;
+ max-width: 320px;
+}
+
+.coll-popover-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.coll-popover-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 2px 4px;
+ line-height: 1;
+ transition: color 0.15s;
+}
+
+.coll-popover-close:hover {
+ color: #fff;
+}
+
+.coll-popover-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 6px;
+}
+
+.coll-popover-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ background: rgba(91, 158, 240, 0.12);
+ border: 1px solid rgba(91, 158, 240, 0.3);
+ color: var(--accent);
+}
+
+.coll-popover-suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 6px;
+}
+
+.coll-popover-suggest-btn {
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ background: var(--bg);
+ border: 1px dashed var(--border);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+
+.coll-popover-suggest-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.coll-popover-add-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+/* ── 11. Loading / Spinner / Empty ───────────────────────── */
+
+#coll-loading {
+ position: fixed;
+ inset: 0;
+ background: rgba(12, 14, 20, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+
+.coll-spinner {
+ display: inline-block;
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--border);
+ border-top: 3px solid var(--accent);
+ border-radius: 50%;
+ animation: coll-spin 0.8s linear infinite;
+}
+
+.coll-spinner-inline {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--border);
+ border-top: 2px solid var(--accent);
+ border-radius: 50%;
+ animation: coll-spin 0.8s linear infinite;
+ vertical-align: middle;
+}
+
+.coll-empty {
+ text-align: center;
+ padding: 32px;
+ color: var(--text-secondary);
+}
+
+.coll-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 24px;
+}
+
+.coll-empty-icon {
+ font-size: 48px;
+}
+
+/* ── 12. Responsive ──────────────────────────────────────── */
+
+@media (max-width: 1024px) {
+ .coll-filters {
+ width: 200px;
+ }
+
+ .coll-drawer {
+ width: 360px;
+ }
+}
+
+@media (max-width: 768px) {
+ .coll-main {
+ flex-direction: column;
+ }
+
+ .coll-filters {
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ overflow-x: auto;
+ position: static;
+ max-height: none;
+ display: flex;
+ gap: 12px;
+ }
+
+ .coll-filter-section {
+ min-width: 180px;
+ margin-bottom: 0;
+ }
+
+ .coll-drawer {
+ width: 100%;
+ }
+
+ .coll-topbar {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 16px;
+ }
+
+ .coll-search {
+ width: 100%;
+ order: 3;
+ }
+
+ .coll-table-container {
+ overflow-x: auto;
+ }
+
+ .coll-source-list {
+ flex-direction: column;
+ }
+}
+
+/* ── 13. Scrollbar Styling (global for collection page) ──── */
+
+#collection-page ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+#collection-page ::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+#collection-page ::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+#collection-page ::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* ── 14. Card Scanner Modal ────────────────────────────── */
+
+.coll-scan-content {
+ padding: 0 4px;
+}
+
+.coll-scan-upload-area {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 8px 0;
+}
+
+.coll-scan-dropzone {
+ border: 2px dashed var(--border);
+ border-radius: 8px;
+ min-height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: border-color 0.2s, background 0.2s;
+ overflow: hidden;
+ position: relative;
+}
+
+.coll-scan-dropzone:hover,
+.coll-scan-dropzone.drag-over {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.04);
+}
+
+.coll-scan-dropzone-text {
+ color: var(--text-secondary);
+ font-size: 14px;
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.coll-scan-preview-img {
+ max-width: 100%;
+ max-height: 360px;
+ object-fit: contain;
+ border-radius: 4px;
+}
+
+.coll-scan-options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.coll-scan-mode {
+ display: flex;
+ gap: 20px;
+}
+
+.coll-radio-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.coll-radio-label input[type="radio"] {
+ accent-color: var(--accent);
+}
+
+.coll-scan-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.coll-scan-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 16px 0 4px;
+ border-top: 1px solid var(--border);
+ margin-top: 12px;
+}
+
+.coll-scan-footer .coll-btn-primary.scanning {
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+/* Scan Results */
+
+.coll-scan-results-wrap {
+ padding: 4px 0;
+}
+
+.coll-scan-results-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-bottom: 10px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.coll-btn-link {
+ background: none;
+ border: none;
+ color: var(--accent);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 4px;
+ text-decoration: underline;
+ text-decoration-color: transparent;
+ transition: text-decoration-color 0.15s;
+}
+
+.coll-btn-link:hover {
+ text-decoration-color: var(--accent);
+}
+
+.coll-scan-results-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 420px;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.coll-scan-result-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ transition: border-color 0.15s;
+}
+
+.coll-scan-result-row:hover {
+ border-color: var(--text-secondary);
+}
+
+.coll-scan-result-row.coll-scan-error {
+ border-color: rgba(248, 81, 73, 0.3);
+ background: rgba(248, 81, 73, 0.06);
+}
+
+.coll-scan-result-check {
+ flex-shrink: 0;
+}
+
+.coll-scan-result-check input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.coll-scan-result-img {
+ width: 56px;
+ height: 78px;
+ object-fit: cover;
+ border-radius: 4px;
+ flex-shrink: 0;
+ background: var(--bg);
+}
+
+.coll-scan-result-img-placeholder {
+ width: 56px;
+ height: 78px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ color: var(--text-secondary);
+ background: var(--bg);
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.coll-scan-result-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.coll-scan-result-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.coll-scan-result-meta {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.coll-scan-result-err {
+ font-size: 12px;
+ color: var(--lab-danger, var(--error));
+}
+
+.coll-scan-conf-high { color: var(--lab-success, var(--success)); font-weight: 500; }
+.coll-scan-conf-medium { color: var(--lab-warning, var(--warning)); font-weight: 500; }
+.coll-scan-conf-low { color: var(--lab-danger, var(--error)); font-weight: 500; }
+
+.coll-scan-result-qty {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
+.coll-scan-result-qty label {
+ font-size: 10px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.coll-scan-qty-input {
+ width: 48px;
+ padding: 4px 6px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 13px;
+ text-align: center;
+}
+
+.coll-scan-qty-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+/* Scan status messages */
+.coll-scan-status {
+ padding: 0 4px;
+}
+
+.coll-scan-progress {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ padding: 8px 0;
+}
+
+.coll-scan-success {
+ color: var(--lab-success, var(--success));
+ font-size: 13px;
+ padding: 8px 0;
+ font-weight: 500;
+}
+
+.coll-scan-error-msg {
+ color: var(--lab-danger, var(--error));
+ font-size: 13px;
+ padding: 8px 0;
+}
+
+/* Manual name entry */
+
+.coll-scan-result-err-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+}
+
+.coll-scan-manual-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.coll-scan-manual-input {
+ flex: 1;
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 13px;
+}
+
+.coll-scan-manual-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.coll-scan-manual-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.coll-btn-sm {
+ padding: 5px 12px;
+ font-size: 12px;
+ background: var(--surface-1);
+ color: var(--accent);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.coll-btn-sm:hover {
+ border-color: var(--accent);
+ background: rgba(91, 158, 240, 0.08);
+}
+
+.coll-scan-manual-add {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ padding: 12px 0 4px;
+ border-top: 1px solid var(--border);
+ margin-top: 8px;
+}
+
+.coll-scan-manual-input-bottom {
+ min-width: 0;
+}
+
+.coll-scan-result-row.coll-scan-manual-added {
+ border-color: rgba(63, 185, 80, 0.3);
+ background: rgba(63, 185, 80, 0.04);
+}
+
+.coll-scan-remove-manual {
+ flex-shrink: 0;
+ padding: 4px 8px;
+ color: var(--text-secondary);
+ border-color: transparent;
+ background: transparent;
+}
+
+.coll-scan-remove-manual:hover {
+ color: var(--lab-danger, var(--error));
+}
+
+/* ── 15. Animations ──────────────────────────────────────── */
+
+/* ── Sort Bar ─────────────────────────────────────────── */
+
+.coll-sort-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ margin-bottom: 8px;
+ background: var(--card-bg, #1e1e2e);
+ border: 1px solid var(--border, #2a2a3a);
+ border-radius: 8px;
+ font-size: 13px;
+ color: var(--text-secondary, #999);
+}
+
+.coll-sort-bar-label {
+ font-weight: 600;
+ color: var(--text-secondary, #999);
+ white-space: nowrap;
+}
+
+.coll-sort-select {
+ background: var(--bg, #121220);
+ color: var(--text, #e0e0e0);
+ border: 1px solid var(--border, #2a2a3a);
+ border-radius: 6px;
+ padding: 5px 10px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+
+.coll-sort-select:hover,
+.coll-sort-select:focus {
+ border-color: var(--accent, #6c63ff);
+ outline: none;
+}
+
+.coll-sort-dir-btn {
+ background: var(--bg, #121220);
+ color: var(--text, #e0e0e0);
+ border: 1px solid var(--border, #2a2a3a);
+ border-radius: 6px;
+ padding: 5px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: border-color 0.15s, background 0.15s;
+ white-space: nowrap;
+}
+
+.coll-sort-dir-btn:hover {
+ border-color: var(--accent, #6c63ff);
+ background: rgba(108, 99, 255, 0.1);
+}
+
+.coll-sort-bar-count {
+ margin-left: auto;
+ font-weight: 500;
+ color: var(--text-secondary, #999);
+ white-space: nowrap;
+}
+
+@keyframes coll-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes coll-slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes coll-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* -- 16. Card Image Hover Preview -------------------------------- */
+#coll-card-preview {
+ position: fixed;
+ pointer-events: none;
+ z-index: 1100;
+ display: none;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
+ overflow: hidden;
+}
+
+#coll-card-preview-img {
+ display: block;
+ width: 220px;
+ height: auto;
+ border-radius: 12px;
+}
+
+/* ── 17. Stats Dashboard ─────────────────────────────────────── */
+
+.coll-stats-btn {
+ background: rgba(99,102,241,0.15);
+ border: 1px solid rgba(99,102,241,0.4);
+ color: var(--accent, #6366f1);
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s, border-color 0.15s;
+}
+.coll-stats-btn:hover {
+ background: rgba(99,102,241,0.3);
+ border-color: var(--accent, #6366f1);
+}
+
+.coll-stats-panel {
+ background: var(--surface, #161b22);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 10px;
+ padding: 20px 24px;
+ margin: 0 0 12px 0;
+ animation: coll-fade-in 0.2s ease;
+}
+
+.coll-stats-loading {
+ color: var(--text-secondary, #8b949e);
+ padding: 16px;
+ text-align: center;
+}
+
+.coll-stats-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+}
+
+/* Summary KPI cards */
+.coll-stats-summary {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.coll-stat-card {
+ flex: 1;
+ min-width: 100px;
+ background: var(--bg, #0d1117);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 8px;
+ padding: 12px 16px;
+ text-align: center;
+}
+.coll-stat-num {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text, #e6edf3);
+ line-height: 1.2;
+}
+.coll-stat-label {
+ font-size: 0.72rem;
+ color: var(--text-secondary, #8b949e);
+ margin-top: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+/* Section headers */
+.coll-stats-section {
+ border-top: 1px solid var(--border, #30363d);
+ padding-top: 14px;
+}
+.coll-stats-section h4 {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-secondary, #8b949e);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin: 0 0 10px 0;
+}
+
+/* CMC vertical bar chart */
+.coll-stat-bar-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+ height: 64px;
+ padding-bottom: 20px;
+ position: relative;
+}
+.coll-stat-bar-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ height: 100%;
+ justify-content: flex-end;
+ position: relative;
+}
+.coll-stat-bar {
+ width: 100%;
+ background: var(--accent, #6366f1);
+ border-radius: 3px 3px 0 0;
+ min-height: 2px;
+ transition: height 0.3s ease;
+}
+.coll-stat-bar-label {
+ position: absolute;
+ bottom: -18px;
+ font-size: 0.68rem;
+ color: var(--text-secondary, #8b949e);
+}
+
+/* Horizontal bar rows (colors, types, rarity, categories) */
+.coll-stat-color-bar,
+.coll-stat-type-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 5px;
+}
+.coll-stat-color-pip {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.65rem;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+.coll-stat-type-label {
+ width: 90px;
+ font-size: 0.75rem;
+ color: var(--text, #e6edf3);
+ flex-shrink: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.coll-stat-hbar-track {
+ flex: 1;
+ height: 10px;
+ background: var(--bg, #0d1117);
+ border-radius: 5px;
+ overflow: hidden;
+ border: 1px solid var(--border, #30363d);
+}
+.coll-stat-hbar {
+ height: 100%;
+ border-radius: 5px;
+ transition: width 0.4s ease;
+}
+.coll-stat-hbar-type {
+ background: var(--accent, #6366f1);
+}
+.coll-stat-hbar-cat {
+ background: #2ea043;
+}
+.coll-stat-hbar-val {
+ width: 36px;
+ font-size: 0.72rem;
+ color: var(--text-secondary, #8b949e);
+ text-align: right;
+ flex-shrink: 0;
+}
+
+/* Top value cards list */
+.coll-stat-top-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.78rem;
+ padding: 3px 0;
+ border-bottom: 1px solid var(--border, #30363d);
+ gap: 8px;
+}
+.coll-stat-top-row span:first-child {
+ color: var(--text, #e6edf3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.coll-stat-top-row span:last-child {
+ color: #3fb950;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.coll-stats-empty {
+ color: var(--text-secondary, #8b949e);
+ font-size: 0.78rem;
+ font-style: italic;
+}
+
+@media (min-width: 900px) {
+ .coll-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .coll-stats-summary {
+ grid-column: 1 / -1;
+ }
+}
+@media (min-width: 1400px) {
+ .coll-stats-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+
+
+/* ── 18. Column Visibility Panel ───────────────────────────── */
+
+.coll-col-toggle-btn {
+ background: rgba(139,148,158,0.1);
+ border: 1px solid var(--border, #30363d);
+ color: var(--text-secondary, #8b949e);
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s;
+}
+.coll-col-toggle-btn:hover {
+ background: rgba(139,148,158,0.2);
+ color: var(--text, #e6edf3);
+}
+
+.coll-col-panel {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 500;
+ background: var(--surface, #161b22);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 10px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ min-width: 180px;
+ animation: coll-fade-in 0.15s ease;
+}
+
+.coll-col-panel-inner {
+ padding: 10px 12px 12px;
+}
+
+.coll-col-panel-hdr {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--text-secondary, #8b949e);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 8px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--border, #30363d);
+}
+
+.coll-col-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary, #8b949e);
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0 4px;
+ line-height: 1;
+}
+.coll-col-close:hover { color: var(--text, #e6edf3); }
+
+.coll-col-toggle-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ font-size: 0.8rem;
+ color: var(--text, #e6edf3);
+ cursor: pointer;
+ user-select: none;
+}
+.coll-col-toggle-item input[type="checkbox"] {
+ accent-color: var(--accent, #6366f1);
+ cursor: pointer;
+}
+.coll-col-toggle-item:hover {
+ color: var(--accent, #6366f1);
+}
+
+.coll-col-reset {
+ margin-top: 10px;
+ width: 100%;
+ background: rgba(99,102,241,0.1);
+ border: 1px solid rgba(99,102,241,0.3);
+ color: var(--accent, #6366f1);
+ border-radius: 5px;
+ padding: 5px 8px;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.coll-col-reset:hover {
+ background: rgba(99,102,241,0.25);
+}
+
+
+/* ── Bulk Action Bar ──────────────────────────────────────────── */
+.coll-bulk-bar {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ background: var(--bg-card, #1e1e1e);
+ border-top: 1px solid var(--border, #30363d);
+ border-radius: 0 0 8px 8px;
+ z-index: 50;
+ flex-wrap: wrap;
+ box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
+}
+.coll-bulk-count {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--accent, #6366f1);
+ min-width: 80px;
+}
+.coll-bulk-sep {
+ color: var(--text-secondary, #8b949e);
+ padding: 0 2px;
+}
+.coll-bulk-label {
+ font-size: 0.82rem;
+ color: var(--text-secondary, #8b949e);
+}
+.coll-bulk-btn {
+ background: rgba(99,102,241,0.15);
+ border: 1px solid rgba(99,102,241,0.35);
+ color: var(--text, #e6edf3);
+ border-radius: 5px;
+ padding: 4px 10px;
+ font-size: 0.82rem;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.coll-bulk-btn:hover:not(:disabled) {
+ background: rgba(99,102,241,0.3);
+}
+.coll-bulk-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.coll-bulk-set-btn {
+ background: rgba(99,102,241,0.25);
+ font-weight: 600;
+}
+.coll-bulk-qty-input {
+ width: 56px;
+ background: var(--bg, #0d1117);
+ border: 1px solid var(--border, #30363d);
+ color: var(--text, #e6edf3);
+ border-radius: 5px;
+ padding: 3px 6px;
+ font-size: 0.82rem;
+ text-align: center;
+}
+
+/* ── Select Checkbox Column ─────────────────────────────────── */
+.coll-th-select {
+ width: 32px;
+ min-width: 32px;
+ text-align: center;
+ padding: 4px;
+}
+.coll-td-select {
+ width: 32px;
+ min-width: 32px;
+ text-align: center;
+ padding: 4px;
+}
+.coll-row-check {
+ cursor: pointer;
+ width: 14px;
+ height: 14px;
+ accent-color: var(--accent, #6366f1);
+}
+.coll-row.selected-row {
+ background: rgba(99,102,241,0.08);
+}
\ No newline at end of file
diff --git a/lan-client/collection.html b/lan-client/collection.html
new file mode 100644
index 0000000..81269b9
--- /dev/null
+++ b/lan-client/collection.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Commander AI Lab — Collection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lan-client/collection.js b/lan-client/collection.js
new file mode 100644
index 0000000..135e80d
--- /dev/null
+++ b/lan-client/collection.js
@@ -0,0 +1,3197 @@
+/**
+ * Commander AI Lab — Collection Management UI
+ * ═══════════════════════════════════════════
+ *
+ * Endpoints consumed:
+ * GET /api/collection — list with filters/pagination/sort
+ * GET /api/collection/{id} — card detail
+ * PATCH /api/collection/{id} — update card (category, tags, notes, finish)
+ * POST /api/collection/import — import cards
+ * GET /api/collection/export — export cards (file download)
+ * GET /api/collection/{id}/edhrec — EDHREC recommendations
+ */
+
+const Collection = (() => {
+ 'use strict';
+
+ // ── Configuration ──────────────────────────────────────
+ const API_BASE = window.LAB_API_BASE || window.location.origin;
+ const LS_PREFS_KEY = 'coll_preferences';
+ const DEBOUNCE_MS = 300;
+
+ // ── Card Preview (hover tooltip with delay) ────────────
+ const CollCardPreview = {
+ el: null,
+ img: null,
+ visible: false,
+ _hoverTimer: null,
+ HOVER_DELAY: 225,
+ init() {
+ this.el = document.getElementById('coll-card-preview');
+ this.img = document.getElementById('coll-card-preview-img');
+ },
+ show(imgUrl, mx, my) {
+ if (!imgUrl || !this.el) return;
+ this.img.src = imgUrl;
+ this.img.onerror = () => this.hide();
+ const vw = window.innerWidth, vh = window.innerHeight, w = 220;
+ let x = mx + 16, y = my - 20;
+ if (x + w > vw - 10) x = mx - w - 16;
+ if (y + 310 > vh - 10) y = vh - 320;
+ if (y < 10) y = 10;
+ this.el.style.left = `${x}px`;
+ this.el.style.top = `${y}px`;
+ this.el.style.display = 'block';
+ this.visible = true;
+ },
+ hide() {
+ clearTimeout(this._hoverTimer);
+ if (this.el) this.el.style.display = 'none';
+ this.visible = false;
+ },
+ attachAll() {
+ if (!this.el) this.init();
+ document.querySelectorAll('.coll-row[data-img-url]').forEach(row => {
+ const imgUrl = row.dataset.imgUrl;
+ const nameEl = row.querySelector('.coll-td-name');
+ if (!imgUrl || !nameEl || nameEl._previewBound) return;
+ nameEl._previewBound = true;
+ let lastE = null;
+ nameEl.addEventListener('mouseenter', (e) => {
+ lastE = e;
+ clearTimeout(this._hoverTimer);
+ this._hoverTimer = setTimeout(() => {
+ if (lastE) this.show(imgUrl, lastE.clientX, lastE.clientY);
+ }, this.HOVER_DELAY);
+ });
+ nameEl.addEventListener('mousemove', (e) => {
+ lastE = e;
+ if (this.visible) this.show(imgUrl, e.clientX, e.clientY);
+ });
+ nameEl.addEventListener('mouseleave', () => this.hide());
+ });
+ },
+ };
+ const COLOR_MAP = {
+ W: { label: 'W', bg: '#f9faf4', color: '#3d3929' },
+ U: { label: 'U', bg: '#0e67ab', color: '#fff' },
+ B: { label: 'B', bg: '#2b2424', color: '#ccc' },
+ R: { label: 'R', bg: '#d3202a', color: '#fff' },
+ G: { label: 'G', bg: '#00733e', color: '#fff' },
+ C: { label: 'C', bg: '#706f6f', color: '#fff' },
+ };
+
+ const CARD_TYPES = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land'];
+ const CATEGORY_SUGGESTIONS = ['Ramp', 'Draw', 'Removal', 'Board Wipe', 'Anthem', 'Protection', 'Tutor', 'Counter', 'Token', 'Sacrifice', 'Recursion', 'Graveyard', 'Lifegain', 'Burn', 'Stax', 'Evasion', 'Finisher', 'Combo'];
+
+ const FIELD_OPTIONS = [
+ { value: 'IGNORE', label: '— Ignore —' },
+ { value: 'QUANTITY', label: 'Quantity' },
+ { value: 'NAME', label: 'Name' },
+ { value: 'SET_CODE', label: 'Set Code' },
+ { value: 'SET_CODE_SECONDARY', label: 'Set Code (secondary)' },
+ { value: 'COLLECTOR_NUMBER', label: 'Collector Number' },
+ { value: 'COLLECTOR_NUMBER_SECONDARY',label: 'Collector Number (secondary)' },
+ { value: 'FINISH', label: 'Finish' },
+ { value: 'CONDITION', label: 'Condition' },
+ { value: 'LANGUAGE', label: 'Language' },
+ ];
+
+ const MOXFIELD_DEFAULTS = {
+ 'Count': 'QUANTITY',
+ 'Name': 'NAME',
+ 'Edition': 'SET_CODE',
+ 'Foil': 'FINISH',
+ 'Collector Number': 'COLLECTOR_NUMBER',
+ 'Condition': 'CONDITION',
+ 'Language': 'LANGUAGE',
+ };
+
+ const ARCHIDEKT_DEFAULTS = {
+ 'Quantity': 'QUANTITY',
+ 'Name': 'NAME',
+ 'Set Code': 'SET_CODE',
+ 'Collector Number': 'COLLECTOR_NUMBER',
+ };
+
+ // ── State ───────────────────────────────────────────────
+ let state = {
+ items: [],
+ page: 1,
+ pageSize: 50,
+ total: 0,
+ loading: false,
+ search: '',
+ sortField: 'name',
+ sortDir: 'asc',
+ filters: {
+ colors: [],
+ types: [],
+ isLegendary: false,
+ isBasic: false,
+ isGameChanger: false,
+ highSalt: false,
+ finish: '',
+ cmcMin: null,
+ cmcMax: null,
+ priceMin: null,
+ priceMax: null,
+ category: [],
+ rarity: [],
+ setCode: [],
+ powerMin: null,
+ powerMax: null,
+ toughMin: null,
+ toughMax: null,
+ keyword: [],
+ edhrecMin: null,
+ edhrecMax: null,
+ qtyMin: null,
+ qtyMax: null,
+ },
+ cachedSets: [],
+ cachedKeywords: [],
+ selectedCard: null,
+ drawerOpen: false,
+ drawerTab: 'overview',
+ edhrecData: null,
+ edhrecLoading: false,
+ importStep: 0,
+ importSource: '',
+ importContent: '',
+ importMapping: {},
+ importMode: 'MERGE',
+ importHeaders: [],
+ importMissingFinishNormal: true,
+ exportOpen: false,
+ selectedRows: new Set(),
+ preferences: {
+ pageSize: 50,
+ sortField: 'name',
+ sortDir: 'asc',
+ visibleColumns: ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_code','tcg_price','edhrec_rank','salt_score','category','finish','oracle_text'],
+ },
+ };
+
+ let searchDebounceTimer = null;
+
+ // ── Utilities ───────────────────────────────────────────
+
+ function escapeHtml(str) {
+ if (str == null) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function escapeAttr(str) {
+ return escapeHtml(str);
+ }
+
+ function formatPrice(price) {
+ if (price == null || isNaN(price)) return '—';
+ return `$${Number(price).toFixed(2)}`;
+ }
+
+ function saltClass(score) {
+ if (score == null) return '';
+ if (score < 1) return 'salt-green';
+ if (score < 2) return 'salt-yellow';
+ if (score < 3) return 'salt-orange';
+ return 'salt-red';
+ }
+
+ function truncate(str, len) {
+ if (!str) return '';
+ return str.length > len ? str.slice(0, len) + '…' : str;
+ }
+
+ function renderManaCost(manaCost) {
+ if (!manaCost) return '—';
+ // Parse mana symbols like {1}{U}{B}, {X}, {W/U}, etc.
+ const symbols = manaCost.match(/\{[^}]+\}/g);
+ if (!symbols) return escapeHtml(manaCost);
+ return symbols.map(sym => {
+ const inner = sym.slice(1, -1); // strip { }
+ const ci = COLOR_MAP[inner];
+ if (ci) {
+ return `${ci.label} `;
+ }
+ // Generic/colorless/X/hybrid
+ return `${escapeHtml(inner)} `;
+ }).join('');
+ }
+
+ function renderColorPips(colorIdentity) {
+ if (!colorIdentity || colorIdentity.length === 0) return 'C ';
+ return colorIdentity.map(c => {
+ const ci = COLOR_MAP[c];
+ if (!ci) return '';
+ return `${ci.label} `;
+ }).join('');
+ }
+
+ function renderFinishBadge(finish) {
+ if (!finish || finish === 'NORMAL') return 'Normal ';
+ if (finish === 'FOIL') return 'Foil ';
+ if (finish === 'ETCHED') return 'Etched ';
+ return `${escapeHtml(finish)} `;
+ }
+
+ function renderCategoryChips(categories, cardId) {
+ if (!categories || categories.length === 0) return '— ';
+ return categories.map(cat =>
+ `${escapeHtml(cat)} `
+ ).join('');
+ }
+
+ function showLoading(show) {
+ const spinner = document.getElementById('coll-loading');
+ if (spinner) spinner.style.display = show ? 'flex' : 'none';
+ }
+
+ // ── Preferences ─────────────────────────────────────────
+
+ function loadPreferences() {
+ try {
+ const saved = localStorage.getItem(LS_PREFS_KEY);
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ state.preferences = { ...state.preferences, ...parsed };
+ state.pageSize = state.preferences.pageSize;
+ state.sortField = state.preferences.sortField;
+ state.sortDir = state.preferences.sortDir;
+ }
+ } catch (e) {
+ console.warn('[Collection] Failed to load preferences:', e);
+ }
+ }
+
+ function savePreferences() {
+ try {
+ state.preferences.pageSize = state.pageSize;
+ state.preferences.sortField = state.sortField;
+ state.preferences.sortDir = state.sortDir;
+ localStorage.setItem(LS_PREFS_KEY, JSON.stringify(state.preferences));
+ } catch (e) {
+ console.warn('[Collection] Failed to save preferences:', e);
+ }
+ }
+
+ // ── URL Sync ────────────────────────────────────────────
+
+ function syncStateFromUrl() {
+ const params = new URLSearchParams(window.location.search);
+ if (params.has('q')) state.search = params.get('q');
+ if (params.has('page')) state.page = parseInt(params.get('page'), 10) || 1;
+ if (params.has('pageSize')) state.pageSize = parseInt(params.get('pageSize'), 10) || 50;
+ if (params.has('sortField')) state.sortField = params.get('sortField');
+ if (params.has('sortDir')) state.sortDir = params.get('sortDir');
+ if (params.has('colors')) state.filters.colors = params.get('colors').split(',').filter(Boolean);
+ if (params.has('types')) state.filters.types = params.get('types').split(',').filter(Boolean);
+ if (params.has('finish')) state.filters.finish = params.get('finish');
+ if (params.has('category')) state.filters.category = params.get('category').split(',').filter(Boolean);
+ if (params.has('isLegendary')) state.filters.isLegendary = params.get('isLegendary') === 'true';
+ if (params.has('isBasic')) state.filters.isBasic = params.get('isBasic') === 'true';
+ if (params.has('isGameChanger')) state.filters.isGameChanger = params.get('isGameChanger') === 'true';
+ if (params.has('highSalt')) state.filters.highSalt = params.get('highSalt') === 'true';
+ if (params.has('cmcMin')) state.filters.cmcMin = parseFloat(params.get('cmcMin'));
+ if (params.has('cmcMax')) state.filters.cmcMax = parseFloat(params.get('cmcMax'));
+ if (params.has('priceMin')) state.filters.priceMin = parseFloat(params.get('priceMin'));
+ if (params.has('priceMax')) state.filters.priceMax = parseFloat(params.get('priceMax'));
+ if (params.has('rarity')) state.filters.rarity = params.get('rarity').split(',').filter(Boolean);
+ if (params.has('setCode')) state.filters.setCode = params.get('setCode').split(',').filter(Boolean);
+ if (params.has('powerMin')) state.filters.powerMin = parseFloat(params.get('powerMin'));
+ if (params.has('powerMax')) state.filters.powerMax = parseFloat(params.get('powerMax'));
+ if (params.has('toughMin')) state.filters.toughMin = parseFloat(params.get('toughMin'));
+ if (params.has('toughMax')) state.filters.toughMax = parseFloat(params.get('toughMax'));
+ if (params.has('keyword')) state.filters.keyword = params.get('keyword').split(',').filter(Boolean);
+ if (params.has('edhrecMin')) state.filters.edhrecMin = parseInt(params.get('edhrecMin'));
+ if (params.has('edhrecMax')) state.filters.edhrecMax = parseInt(params.get('edhrecMax'));
+ if (params.has('qtyMin')) state.filters.qtyMin = parseInt(params.get('qtyMin'));
+ if (params.has('qtyMax')) state.filters.qtyMax = parseInt(params.get('qtyMax'));
+ }
+
+ function pushUrlState() {
+ const params = new URLSearchParams();
+ if (state.search) params.set('q', state.search);
+ if (state.page > 1) params.set('page', state.page);
+ if (state.pageSize !== 50) params.set('pageSize', state.pageSize);
+ if (state.sortField !== 'name') params.set('sortField', state.sortField);
+ if (state.sortDir !== 'asc') params.set('sortDir', state.sortDir);
+ if (state.filters.colors.length) params.set('colors', state.filters.colors.join(','));
+ if (state.filters.types.length) params.set('types', state.filters.types.join(','));
+ if (state.filters.finish) params.set('finish', state.filters.finish);
+ if (state.filters.category.length) params.set('category', state.filters.category.join(','));
+ if (state.filters.isLegendary) params.set('isLegendary', 'true');
+ if (state.filters.isBasic) params.set('isBasic', 'true');
+ if (state.filters.isGameChanger) params.set('isGameChanger', 'true');
+ if (state.filters.highSalt) params.set('highSalt', 'true');
+ if (state.filters.cmcMin != null) params.set('cmcMin', state.filters.cmcMin);
+ if (state.filters.cmcMax != null) params.set('cmcMax', state.filters.cmcMax);
+ if (state.filters.priceMin != null) params.set('priceMin', state.filters.priceMin);
+ if (state.filters.priceMax != null) params.set('priceMax', state.filters.priceMax);
+ if (state.filters.rarity.length) params.set('rarity', state.filters.rarity.join(','));
+ if (state.filters.setCode.length) params.set('setCode', state.filters.setCode.join(','));
+ if (state.filters.powerMin != null) params.set('powerMin', state.filters.powerMin);
+ if (state.filters.powerMax != null) params.set('powerMax', state.filters.powerMax);
+ if (state.filters.toughMin != null) params.set('toughMin', state.filters.toughMin);
+ if (state.filters.toughMax != null) params.set('toughMax', state.filters.toughMax);
+ if (state.filters.keyword.length) params.set('keyword', state.filters.keyword.join(','));
+ if (state.filters.edhrecMin != null) params.set('edhrecMin', state.filters.edhrecMin);
+ if (state.filters.edhrecMax != null) params.set('edhrecMax', state.filters.edhrecMax);
+ if (state.filters.qtyMin != null) params.set('qtyMin', state.filters.qtyMin);
+ if (state.filters.qtyMax != null) params.set('qtyMax', state.filters.qtyMax);
+ const qs = params.toString();
+ history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
+ }
+
+ // ── API ─────────────────────────────────────────────────
+
+ function buildCollectionQuery() {
+ const p = new URLSearchParams();
+ p.set('page', state.page);
+ p.set('pageSize', state.pageSize);
+ p.set('sortField', state.sortField);
+ p.set('sortDir', state.sortDir);
+ if (state.search) p.set('q', state.search);
+ const f = state.filters;
+ if (f.colors.length) p.set('colors', f.colors.join(','));
+ if (f.types.length) p.set('types', f.types.join(','));
+ if (f.finish) p.set('finish', f.finish);
+ if (f.category.length) p.set('category', f.category.join(','));
+ if (f.isLegendary) p.set('isLegendary', 'true');
+ if (f.isBasic) p.set('isBasic', 'true');
+ if (f.isGameChanger) p.set('isGameChanger', 'true');
+ if (f.highSalt) p.set('highSalt', 'true');
+ if (f.cmcMin != null) p.set('cmcMin', f.cmcMin);
+ if (f.cmcMax != null) p.set('cmcMax', f.cmcMax);
+ if (f.priceMin != null) p.set('priceMin', f.priceMin);
+ if (f.priceMax != null) p.set('priceMax', f.priceMax);
+ if (f.rarity.length) p.set('rarity', f.rarity.join(','));
+ if (f.setCode.length) p.set('setCode', f.setCode.join(','));
+ if (f.powerMin != null) p.set('powerMin', f.powerMin);
+ if (f.powerMax != null) p.set('powerMax', f.powerMax);
+ if (f.toughMin != null) p.set('toughMin', f.toughMin);
+ if (f.toughMax != null) p.set('toughMax', f.toughMax);
+ if (f.keyword.length) p.set('keyword', f.keyword.join(','));
+ if (f.edhrecMin != null) p.set('edhrecMin', f.edhrecMin);
+ if (f.edhrecMax != null) p.set('edhrecMax', f.edhrecMax);
+ if (f.qtyMin != null) p.set('qtyMin', f.qtyMin);
+ if (f.qtyMax != null) p.set('qtyMax', f.qtyMax);
+ return p.toString();
+ }
+
+ async function fetchCollection() {
+ if (state.loading) return;
+ state.loading = true;
+ showLoading(true);
+ try {
+ const qs = buildCollectionQuery();
+ const res = await fetch(`${API_BASE}/api/collection?${qs}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ state.items = data.items || [];
+ state.total = data.total || 0;
+ state.page = data.page || state.page;
+ } catch (err) {
+ console.error('[Collection] fetchCollection error:', err);
+ state.items = [];
+ state.total = 0;
+ } finally {
+ state.loading = false;
+ showLoading(false);
+ }
+ renderTable();
+ renderSortBar();
+ renderPagination();
+ renderCountBadge();
+ }
+
+ // ── Stats Dashboard ──────────────────────────────────────
+ let statsData = null;
+ let statsOpen = false;
+
+ async function fetchStats() {
+ try {
+ const res = await fetch(`${API_BASE}/api/collection/stats`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ statsData = await res.json();
+ } catch (err) {
+ console.error('[Collection] fetchStats error:', err);
+ statsData = null;
+ }
+ }
+
+ function toggleStats() {
+ statsOpen = !statsOpen;
+ const panel = document.getElementById('coll-stats-panel');
+ if (!panel) return;
+ if (statsOpen) {
+ if (!statsData) {
+ panel.innerHTML = 'Loading stats…
';
+ panel.style.display = 'block';
+ fetchStats().then(() => renderStatsPanel());
+ } else {
+ renderStatsPanel();
+ }
+ panel.style.display = 'block';
+ } else {
+ panel.style.display = 'none';
+ }
+ }
+
+ function renderStatsPanel() {
+ const panel = document.getElementById('coll-stats-panel');
+ if (!panel || !statsData) return;
+ const s = statsData;
+
+ // CMC curve bar chart (CSS-only)
+ const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
+ const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
+ const count = (s.cmcCurve || {})[String(i)] || 0;
+ const pct = Math.round((count / cmcMax) * 100);
+ const label = i === 7 ? '7+' : String(i);
+ return ``;
+ }).join('');
+
+ // Color distribution
+ const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
+ const colorBars = ['W','U','B','R','G','C'].map(c => {
+ const count = (s.colorDistribution || {})[c] || 0;
+ const pct = Math.round((count / colorTotal) * 100);
+ const ci = COLOR_MAP[c];
+ return `
+
${ci.label}
+
+
${count}
+
`;
+ }).join('');
+
+ // Type distribution
+ const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
+ const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
+ const pct = Math.round((count / typeTotal) * 100);
+ return `
+
${escapeHtml(t)}
+
+
${count}
+
`;
+ }).join('');
+
+ // Rarity distribution
+ const rarityOrder = ['common','uncommon','rare','mythic'];
+ const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
+ const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
+ const rarityBars = rarityOrder.map(r => {
+ const count = (s.rarityDistribution || {})[r] || 0;
+ const pct = Math.round((count / rarityTotal) * 100);
+ return `
+
${r.charAt(0).toUpperCase() + r.slice(1)}
+
+
${count}
+
`;
+ }).join('');
+
+ // Top value cards
+ const topCards = (s.topValueCards || []).map(c =>
+ `${escapeHtml(c.name)} $${Number(c.price).toFixed(2)}
`
+ ).join('');
+
+ // Category/role coverage
+ const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
+ const catMax = Math.max(...catEntries.map(e => e[1]), 1);
+ const catBars = catEntries.map(([cat, count]) => {
+ const pct = Math.round((count / catMax) * 100);
+ return `
+
${escapeHtml(cat)}
+
+
${count}
+
`;
+ }).join('');
+
+ panel.innerHTML = `
+
+
+
${(s.totalCards || 0).toLocaleString()}
Total Cards
+
${(s.totalUniqueCards || 0).toLocaleString()}
Unique
+
$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}
Total Value
+
+
+
+
CMC Curve
+
${cmcBars}
+
+
+
Color Distribution
+ ${colorBars}
+
+
+
Type Distribution
+ ${typeBars}
+
+
+
Rarity
+ ${rarityBars}
+
+
+
Role Coverage
+ ${catBars || '
No categories assigned yet
'}
+
+
+
Top Value Cards
+ ${topCards || '
No priced cards
'}
+
+
+ `;
+ }
+
+
+ async function fetchCardDetail(cardId) {
+ const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ }
+
+ async function fetchEdhrecData(cardId) {
+ const res = await fetch(`${API_BASE}/api/collection/${cardId}/edhrec`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ }
+
+ async function patchCard(cardId, payload) {
+ const res = await fetch(`${API_BASE}/api/collection/${cardId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ }
+
+ // ── Init ────────────────────────────────────────────────
+
+ async function init() {
+ loadPreferences();
+ syncStateFromUrl();
+ renderPage();
+ bindEvents();
+ adjustTableHeight();
+ window.addEventListener('resize', adjustTableHeight);
+ // Pre-load set/keyword caches for filter autocomplete
+ loadSetsCache();
+ loadKeywordsCache();
+ applyColumnVisibility();
+ await fetchCollection();
+ }
+
+ /** Adjust table container height based on actual topbar height */
+ function adjustTableHeight() {
+ const topbar = document.querySelector('.coll-topbar');
+ const tableWrap = document.querySelector('.coll-table-wrap');
+ if (topbar && tableWrap) {
+ const topbarH = topbar.offsetHeight;
+ // 16px padding-top of coll-main + 16px padding-bottom
+ tableWrap.style.maxHeight = 'calc(100vh - ' + (topbarH + 48) + 'px)';
+ }
+ }
+
+ // ── Page Render ─────────────────────────────────────────
+
+ function renderPage() {
+ const container = document.getElementById('collection-container');
+ if (!container) return;
+
+ container.innerHTML = `
+
+
+
+
+
+
+
+
+
Collection
+ 0 cards
+
+
+
+
+
+
+
+
+
+
+
+ ${renderFiltersHtml()}
+
+
+
+
+ ${renderSortBarHtml()}
+
+
+ ${renderTableHeaderHtml()}
+
+
+
+
+
+
+ 0 selected
+ Select All
+ Clear
+ |
+ Qty:
+ −
+ +
+
+ Set
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ renderTable();
+ renderPagination();
+ }
+
+ // ── Sort Bar HTML ───────────────────────────────────────
+
+ function renderSortBarHtml() {
+ const sortOptions = [
+ { value: 'name', label: 'Name' },
+ { value: 'tcg_price', label: 'Price' },
+ { value: 'cmc', label: 'CMC / Mana Value' },
+ { value: 'quantity', label: 'Quantity' },
+ { value: 'type_line', label: 'Type' },
+ { value: 'rarity', label: 'Rarity' },
+ { value: 'edhrec_rank',label: 'EDHREC Rank' },
+ { value: 'salt_score', label: 'Salt Score' },
+ { value: 'set_code', label: 'Set' },
+ { value: 'finish', label: 'Finish' },
+ ];
+ const opts = sortOptions.map(o =>
+ `${o.label} `
+ ).join('');
+ const dirLabel = state.sortDir === 'asc' ? '▲ Asc' : '▼ Desc';
+ return `
+
+ Sort by
+
+ ${opts}
+
+ ${dirLabel}
+ ${state.total.toLocaleString()} cards
+
`;
+ }
+
+ function renderSortBar() {
+ const bar = document.querySelector('.coll-sort-bar');
+ if (bar) {
+ bar.outerHTML = renderSortBarHtml();
+ }
+ }
+
+ function setSortFromBar(field) {
+ state.sortField = field;
+ state.page = 1;
+ savePreferences();
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function toggleSortDir() {
+ state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
+ state.page = 1;
+ savePreferences();
+ pushUrlState();
+ fetchCollection();
+ }
+
+ // ── Filter Panel HTML ───────────────────────────────────
+
+ function renderFiltersHtml() {
+ const f = state.filters;
+
+ // Color buttons
+ const colorBtns = Object.entries(COLOR_MAP).map(([key, ci]) => {
+ const active = f.colors.includes(key) ? 'active' : '';
+ return `${ci.label} `;
+ }).join('');
+
+ // Type checkboxes
+ const typeChecks = CARD_TYPES.map(t => {
+ const checked = f.types.includes(t) ? 'checked' : '';
+ return `
+ ${t}
+ `;
+ }).join('');
+
+ // Toggles
+ const toggles = [
+ { key: 'isLegendary', label: 'Legendary' },
+ { key: 'isBasic', label: 'Basics' },
+ { key: 'isGameChanger', label: 'Game Changers' },
+ { key: 'highSalt', label: 'High Salt' },
+ ].map(({ key, label }) => {
+ const active = f[key] ? 'active' : '';
+ return `${label} `;
+ }).join('');
+
+ // Finish radios
+ const finishes = [
+ { value: '', label: 'All' },
+ { value: 'NORMAL', label: 'Normal' },
+ { value: 'FOIL', label: 'Foil' },
+ { value: 'ETCHED', label: 'Etched' },
+ ].map(({ value, label }) => {
+ const checked = f.finish === value ? 'checked' : '';
+ return `
+ ${label}
+ `;
+ }).join('');
+
+ // Category chips
+ const catChips = CATEGORY_SUGGESTIONS.map(cat => {
+ const active = f.category.includes(cat) ? 'active' : '';
+ return `${escapeHtml(cat)} `;
+ }).join('');
+
+ // Rarity buttons
+ const RARITIES = [
+ { value: 'common', label: 'C', title: 'Common' },
+ { value: 'uncommon', label: 'U', title: 'Uncommon' },
+ { value: 'rare', label: 'R', title: 'Rare' },
+ { value: 'mythic', label: 'M', title: 'Mythic' },
+ ];
+ const rarityBtns = RARITIES.map(r => {
+ const active = f.rarity.includes(r.value) ? 'active' : '';
+ return `${r.label} `;
+ }).join('');
+
+ // Set filter chips (show selected sets)
+ const setChips = f.setCode.map(code => {
+ const found = state.cachedSets.find(s => s.code === code);
+ const label = found ? found.name : code;
+ return `${escapeHtml(label)} × `;
+ }).join('');
+
+ // Keyword filter chips
+ const kwChips = f.keyword.map(kw =>
+ `${escapeHtml(kw)} × `
+ ).join('');
+
+ return `
+
+
Color Identity
+
${colorBtns}
+
+
+
+
Card Type
+
${typeChecks}
+
+
+
+
Rarity
+
${rarityBtns}
+
+
+
+
Properties
+
${toggles}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Category
+
${catChips}
+
+
+
+ Reset Filters
+
`;
+ }
+
+ // ── Column Visibility ──────────────────────────────────────
+ function colVis(key) {
+ return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;';
+ }
+
+ function toggleColumn(key) {
+ const cols = state.preferences.visibleColumns;
+ const idx = cols.indexOf(key);
+ if (idx >= 0) {
+ if (cols.length <= 1) return; // keep at least 1 column
+ cols.splice(idx, 1);
+ } else {
+ cols.push(key);
+ }
+ savePreferences();
+ renderTable();
+ applyColumnVisibility();
+ }
+
+ function renderColumnPanel() {
+ const panel = document.getElementById('coll-col-panel');
+ if (!panel) return;
+ const cols = COLUMNS.map(col => {
+ const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : '';
+ return `
+
+ ${escapeHtml(col.label)}
+ `;
+ }).join('');
+ panel.innerHTML = `
+
+
Columns ×
+ ${cols}
+
Reset Defaults
+
`;
+ panel.style.display = 'block';
+ }
+
+ function openColumnPanel() {
+ const panel = document.getElementById('coll-col-panel');
+ if (!panel) return;
+ if (panel.style.display === 'block') {
+ panel.style.display = 'none';
+ } else {
+ renderColumnPanel();
+ }
+ }
+
+ function closeColumnPanel() {
+ const panel = document.getElementById('coll-col-panel');
+ if (panel) panel.style.display = 'none';
+ }
+
+ function resetColumns() {
+ state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_code','tcg_price','edhrec_rank','salt_score','category','finish','oracle_text'];
+ savePreferences();
+ renderTable();
+ renderColumnPanel();
+ }
+ function applyColumnVisibility() {
+ let styleEl = document.getElementById('coll-col-vis-style');
+ if (!styleEl) {
+ styleEl = document.createElement('style');
+ styleEl.id = 'coll-col-vis-style';
+ document.head.appendChild(styleEl);
+ }
+ const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key));
+ styleEl.textContent = hidden.map(col =>
+ `.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }`
+ ).join('\n');
+ }
+
+
+ // ── Table Header ────────────────────────────────────────
+
+ const COLUMNS = [
+ { key: 'quantity', label: 'Qty', sortable: true },
+ { key: 'name', label: 'Name', sortable: true },
+ { key: 'mana_cost', label: 'Mana', sortable: false },
+ { key: 'type_line', label: 'Type', sortable: true },
+ { key: 'color_identity',label: 'Colors', sortable: false },
+ { key: 'cmc', label: 'CMC', sortable: true },
+ { key: 'power_toughness',label: 'P/T', sortable: false },
+ { key: 'rarity', label: 'Rarity', sortable: true },
+ { key: 'set_code', label: 'Set', sortable: true },
+ { key: 'tcg_price', label: 'Price', sortable: true },
+ { key: 'edhrec_rank', label: 'EDHREC', sortable: true },
+ { key: 'salt_score', label: 'Salt', sortable: true },
+ { key: 'is_game_changer',label: 'GC', sortable: false },
+ { key: 'category', label: 'Category', sortable: true },
+ { key: 'finish', label: 'Finish', sortable: true },
+ { key: 'oracle_text', label: 'Oracle Text', sortable: false },
+ ];
+
+ function renderTableHeaderHtml() {
+ const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key));
+ const headers = visibleCols.map(col => { if (!col.sortable) {
+ return `${col.label} `;
+ }
+ const isActive = state.sortField === col.key;
+ const dir = isActive ? state.sortDir : '';
+ const arrow = isActive ? (dir === 'asc' ? ' ▲' : ' ▼') : '';
+ const activeClass = isActive ? ' coll-th-active' : '';
+ return `${col.label}${arrow} `;
+ }).join('');
+ return ` ${headers} `;
+ }
+
+ // ── Table Body ──────────────────────────────────────────
+
+ function renderTable() {
+ const tbody = document.getElementById('coll-tbody');
+ if (!tbody) return;
+
+ // Re-render header (sort indicators may have changed)
+ const table = document.getElementById('coll-table');
+ if (table) {
+ const oldThead = table.querySelector('thead');
+ if (oldThead) {
+ oldThead.outerHTML = renderTableHeaderHtml();
+ }
+ }
+
+ if (state.loading) {
+ tbody.innerHTML = `Loading… `;
+ return;
+ }
+
+ if (state.items.length === 0) {
+ tbody.innerHTML = `
+
+
🃏
+
No cards in collection
+
Import Cards
+
+ `;
+ return;
+ }
+
+ tbody.innerHTML = state.items.map(card => {
+ const saltCls = saltClass(card.saltScore);
+ const oracleTrunc = truncate(card.oracleText || '', 60);
+ const gcIcon = card.isGameChanger ? '★ ' : '';
+ const legIcon = card.isLegendary ? '◆ ' : '';
+ const basicIcon = card.isBasic ? '⬡ ' : '';
+
+ const manaCost = renderManaCost(card.manaCost || card.mana_cost || '');
+ const pt = (card.power || card.toughness)
+ ? `${escapeHtml(card.power || '—')}/${escapeHtml(card.toughness || '—')}`
+ : '—';
+ const rarity = card.rarity ? `${escapeHtml(card.rarity)} ` : '—';
+ const setName = escapeHtml(card.setName || card.set_name || '');
+ const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—';
+
+ return `
+
+ ${escapeHtml(card.quantity ?? 1)}
+
+ ${escapeHtml(card.name)}
+ ${legIcon}${basicIcon}
+
+ ${manaCost}
+ ${escapeHtml(card.typeLine || card.type_line || '')}
+ ${renderColorPips(card.colorIdentity || card.color_identity)}
+ ${card.cmc ?? '—'}
+ ${pt}
+ ${rarity}
+ ${setName}
+ ${formatPrice(card.tcgPrice || card.tcg_price)}
+ ${edhrecRank}
+ ${(card.saltScore ?? card.salt_score) != null ? Number(card.saltScore ?? card.salt_score).toFixed(2) : '—'}
+ ${gcIcon}
+ ${renderCategoryChips(card.category || [], card.id)}
+ ${renderFinishBadge(card.finish)}
+ ${escapeHtml(oracleTrunc)}
+ `;
+ }).join('');
+ CollCardPreview.attachAll();
+ }
+
+ // ── Pagination ──────────────────────────────────────────
+
+ function renderPagination() {
+ const el = document.getElementById('coll-pagination');
+ if (!el) return;
+ const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
+ const prevDisabled = state.page <= 1 ? 'disabled' : '';
+ const nextDisabled = state.page >= totalPages ? 'disabled' : '';
+
+ el.innerHTML = `
+
+ Page ${state.page} of ${totalPages}
+ (${state.total} total)
+
+
+ ← Prev
+ Next →
+
+
+ Show:
+
+ ${[25, 50, 100].map(n =>
+ `${n} `
+ ).join('')}
+
+
`;
+ }
+
+ function renderCountBadge() {
+ const el = document.getElementById('coll-count');
+ if (el) el.textContent = `${state.total.toLocaleString()} card${state.total !== 1 ? 's' : ''}`;
+ }
+
+ // ── Sort / Page / Filter Actions ─────────────────────────
+
+ function setSort(field) {
+ if (state.sortField === field) {
+ state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
+ } else {
+ state.sortField = field;
+ state.sortDir = 'asc';
+ }
+ state.page = 1;
+ savePreferences();
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setPage(p) {
+ const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
+ state.page = Math.max(1, Math.min(p, totalPages));
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setPageSize(n) {
+ state.pageSize = n;
+ state.page = 1;
+ savePreferences();
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function toggleColor(color) {
+ const idx = state.filters.colors.indexOf(color);
+ if (idx >= 0) {
+ state.filters.colors.splice(idx, 1);
+ } else {
+ state.filters.colors.push(color);
+ }
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ function toggleType(type) {
+ const idx = state.filters.types.indexOf(type);
+ if (idx >= 0) {
+ state.filters.types.splice(idx, 1);
+ } else {
+ state.filters.types.push(type);
+ }
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function toggleFilter(key) {
+ state.filters[key] = !state.filters[key];
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ function setFinish(value) {
+ state.filters.finish = value;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setCmcRange() {
+ const minEl = document.getElementById('coll-cmc-min');
+ const maxEl = document.getElementById('coll-cmc-max');
+ state.filters.cmcMin = minEl?.value !== '' ? parseFloat(minEl.value) : null;
+ state.filters.cmcMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setPriceRange() {
+ const minEl = document.getElementById('coll-price-min');
+ const maxEl = document.getElementById('coll-price-max');
+ state.filters.priceMin = minEl?.value !== '' ? parseFloat(minEl.value) : null;
+ state.filters.priceMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function toggleCategoryFilter(cat) {
+ const idx = state.filters.category.indexOf(cat);
+ if (idx >= 0) {
+ state.filters.category.splice(idx, 1);
+ } else {
+ state.filters.category.push(cat);
+ }
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ // ── New filter action handlers ────────────────────────────
+
+ function toggleRarity(value) {
+ const idx = state.filters.rarity.indexOf(value);
+ if (idx >= 0) state.filters.rarity.splice(idx, 1);
+ else state.filters.rarity.push(value);
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ function setPowerRange() {
+ const minEl = document.getElementById('coll-pow-min');
+ const maxEl = document.getElementById('coll-pow-max');
+ state.filters.powerMin = minEl?.value !== '' ? parseFloat(minEl.value) : null;
+ state.filters.powerMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setToughRange() {
+ const minEl = document.getElementById('coll-tgh-min');
+ const maxEl = document.getElementById('coll-tgh-max');
+ state.filters.toughMin = minEl?.value !== '' ? parseFloat(minEl.value) : null;
+ state.filters.toughMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setEdhrecRange() {
+ const minEl = document.getElementById('coll-edhrec-min');
+ const maxEl = document.getElementById('coll-edhrec-max');
+ state.filters.edhrecMin = minEl?.value !== '' ? parseInt(minEl.value) : null;
+ state.filters.edhrecMax = maxEl?.value !== '' ? parseInt(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ function setQtyRange() {
+ const minEl = document.getElementById('coll-qty-min');
+ const maxEl = document.getElementById('coll-qty-max');
+ state.filters.qtyMin = minEl?.value !== '' ? parseInt(minEl.value) : null;
+ state.filters.qtyMax = maxEl?.value !== '' ? parseInt(maxEl.value) : null;
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+
+ // Set autocomplete
+ async function loadSetsCache() {
+ if (state.cachedSets.length) return;
+ try {
+ const res = await fetch(API_BASE + '/api/collection/sets');
+ if (res.ok) state.cachedSets = await res.json();
+ } catch (e) { /* ignore */ }
+ }
+
+ function onSetInput(val) {
+ const dd = document.getElementById('coll-set-dropdown');
+ if (!dd) return;
+ if (!val || val.length < 1) { dd.innerHTML = ''; dd.style.display = 'none'; return; }
+ const q = val.toLowerCase();
+ const matches = state.cachedSets
+ .filter(s => !state.filters.setCode.includes(s.code))
+ .filter(s => s.code.toLowerCase().includes(q) || (s.name || '').toLowerCase().includes(q))
+ .slice(0, 12);
+ if (!matches.length) { dd.innerHTML = ''; dd.style.display = 'none'; return; }
+ dd.style.display = 'block';
+ dd.innerHTML = matches.map(s =>
+ `
+ ${escapeHtml(s.code)}
+ ${escapeHtml(s.name || '')}
+
`
+ ).join('');
+ }
+
+ function addSetFilter(code) {
+ if (!state.filters.setCode.includes(code)) {
+ state.filters.setCode.push(code);
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+ const dd = document.getElementById('coll-set-dropdown');
+ if (dd) { dd.innerHTML = ''; dd.style.display = 'none'; }
+ }
+
+ function removeSetFilter(code) {
+ state.filters.setCode = state.filters.setCode.filter(c => c !== code);
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ // Keyword autocomplete
+ async function loadKeywordsCache() {
+ if (state.cachedKeywords.length) return;
+ try {
+ const res = await fetch(API_BASE + '/api/collection/keywords');
+ if (res.ok) state.cachedKeywords = await res.json();
+ } catch (e) { /* ignore */ }
+ }
+
+ function onKwInput(val) {
+ const dd = document.getElementById('coll-kw-dropdown');
+ if (!dd) return;
+ if (!val || val.length < 1) { dd.innerHTML = ''; dd.style.display = 'none'; return; }
+ const q = val.toLowerCase();
+ const matches = state.cachedKeywords
+ .filter(k => !state.filters.keyword.includes(k))
+ .filter(k => k.toLowerCase().includes(q))
+ .slice(0, 12);
+ if (!matches.length) { dd.innerHTML = ''; dd.style.display = 'none'; return; }
+ dd.style.display = 'block';
+ dd.innerHTML = matches.map(k =>
+ `
+ ${escapeHtml(k)}
+
`
+ ).join('');
+ }
+
+ function addKeywordFilter(kw) {
+ if (!state.filters.keyword.includes(kw)) {
+ state.filters.keyword.push(kw);
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+ const dd = document.getElementById('coll-kw-dropdown');
+ if (dd) { dd.innerHTML = ''; dd.style.display = 'none'; }
+ }
+
+ function removeKeywordFilter(kw) {
+ state.filters.keyword = state.filters.keyword.filter(k => k !== kw);
+ state.page = 1;
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ function resetFilters() {
+ state.filters = {
+ colors: [], types: [], isLegendary: false, isBasic: false,
+ isGameChanger: false, highSalt: false, finish: '',
+ cmcMin: null, cmcMax: null, priceMin: null, priceMax: null,
+ category: [], rarity: [], setCode: [],
+ powerMin: null, powerMax: null, toughMin: null, toughMax: null,
+ keyword: [], edhrecMin: null, edhrecMax: null,
+ qtyMin: null, qtyMax: null,
+ };
+ state.page = 1;
+ state.search = '';
+ const searchEl = document.getElementById('coll-search');
+ if (searchEl) searchEl.value = '';
+ pushUrlState();
+ refreshFiltersPanel();
+ fetchCollection();
+ }
+
+ function refreshFiltersPanel() {
+ const panel = document.getElementById('coll-filters');
+ if (panel) panel.innerHTML = renderFiltersHtml();
+ }
+
+ // ── Detail Drawer ────────────────────────────────────────
+
+ async function openDrawer(cardId) {
+ state.selectedCard = null;
+ state.edhrecData = null;
+ state.drawerTab = 'overview';
+
+ const drawer = document.getElementById('coll-drawer');
+ const backdrop = document.getElementById('coll-drawer-backdrop');
+ const inner = document.getElementById('coll-drawer-inner');
+ if (!drawer) return;
+
+ drawer.classList.add('open');
+ if (backdrop) backdrop.classList.add('open');
+ state.drawerOpen = true;
+
+ inner.innerHTML = ``;
+
+ try {
+ const card = await fetchCardDetail(cardId);
+ state.selectedCard = card;
+ renderDrawer();
+ } catch (err) {
+ inner.innerHTML = `Failed to load card: ${escapeHtml(err.message)}
`;
+ }
+ }
+
+ function closeDrawer() {
+ const drawer = document.getElementById('coll-drawer');
+ const backdrop = document.getElementById('coll-drawer-backdrop');
+ if (drawer) drawer.classList.remove('open');
+ if (backdrop) backdrop.classList.remove('open');
+ state.drawerOpen = false;
+ state.selectedCard = null;
+ closeCategoryPopover();
+ }
+
+ function renderDrawer() {
+ const inner = document.getElementById('coll-drawer-inner');
+ if (!inner || !state.selectedCard) return;
+ const card = state.selectedCard;
+
+ const tabContent = renderDrawerTabContent();
+
+ inner.innerHTML = `
+
+
+
+
+ ${card.imageUrl
+ ? `
`
+ : `
No Image
`}
+
+
+
+ Overview
+ EDHREC
+ Sample Decks
+
+
+
+ ${tabContent}
+
+
`;
+ }
+
+ function renderDrawerTabContent() {
+ if (state.drawerTab === 'overview') return renderOverviewTab();
+ if (state.drawerTab === 'edhrec') return renderEdhrecTab();
+ if (state.drawerTab === 'decks') return renderDecksTab();
+ return '';
+ }
+
+ function renderOverviewTab() {
+ const card = state.selectedCard;
+ if (!card) return '';
+
+ const categories = Array.isArray(card.category) ? card.category : [];
+ const tags = card.tags ? card.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
+
+ const catChips = categories.map(cat =>
+ `${escapeHtml(cat)}
+ ×
+ `
+ ).join('');
+
+ const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s =>
+ `${escapeHtml(s)} `
+ ).join('');
+
+ const _mc = card.manaCost || card.mana_cost || '';
+ const _pw = card.power || '';
+ const _tg = card.toughness || '';
+ const _pt = (_pw || _tg) ? `${_pw || '—'}/${_tg || '—'}` : '';
+ const _rr = card.rarity || '';
+ const _sn = card.setName || card.set_name || '';
+ const _er = card.edhrecRank || card.edhrec_rank || 0;
+
+ return `
+
+
Mana Cost ${renderManaCost(_mc)}
+
CMC ${card.cmc ?? '—'}
+ ${_pt ? `
P/T ${escapeHtml(_pt)}
` : ''}
+
Rarity ${escapeHtml(_rr || '—')}
+
Set
+ ${escapeHtml(_sn || card.setCode || card.set_code || '')} ${card.collectorNumber || card.collector_number ? `#${escapeHtml(card.collectorNumber || card.collector_number)}` : ''}
+
+
Price ${formatPrice(card.tcgPrice || card.tcg_price)}
+ ${_er ? `
EDHREC Rank #${_er}
` : ''}
+
Salt
+ ${(card.saltScore ?? card.salt_score) != null ? Number(card.saltScore ?? card.salt_score).toFixed(2) : '—'}
+
+
Game Changer
+ ${(card.isGameChanger || card.is_game_changer) ? '★ Yes' : 'No'}
+
+
Finish ${renderFinishBadge(card.finish)}
+ ${card.keywords && card.keywords.length
+ ? `
Keywords ${escapeHtml(card.keywords.join(', '))}
`
+ : ''}
+
+
+ ${card.oracleText
+ ? `${escapeHtml(card.oracleText).replace(/\n/g, ' ')}
`
+ : ''}
+
+
+
Finish
+
+ Normal
+ Foil
+ Etched
+
+
+
+
+
Categories
+
${catChips}
+
${catSuggestions}
+
+
+ Add
+
+
+
+
+
Tags
+
+ ${tags.map(tag =>
+ `${escapeHtml(tag)}
+ ×
+ `
+ ).join('')}
+
+
+
+ Add
+
+
+
+
+
+
+ Save Changes
+
+
`;
+ }
+
+ function renderEdhrecTab() {
+ if (state.edhrecLoading) {
+ return ``;
+ }
+ if (!state.edhrecData) {
+ return `
+
Fetch EDHREC recommendations for this card.
+
Load EDHREC Data
+
`;
+ }
+
+ const data = state.edhrecData;
+ const recs = data.recommendations || [];
+
+ const links = data.links || {};
+ const linksHtml = [
+ links.edhrecPage && `EDHREC Page `,
+ links.archidektSearchUri && `Archidekt Search `,
+ links.moxfieldSearchUri && `Moxfield Search `,
+ ].filter(Boolean).join('');
+
+ if (recs.length === 0) {
+ return `
+
No EDHREC recommendations found.
+ ${linksHtml ? `
${linksHtml}
` : ''}
+
`;
+ }
+
+ const recRows = recs.map(rec => `
+
+
${escapeHtml(rec.name || '')}
+
+ ${rec.role ? `${escapeHtml(rec.role)} ` : ''}
+ ${rec.synergy != null ? `${Number(rec.synergy).toFixed(0)}% ` : ''}
+ ${rec.inclusion != null ? `${Number(rec.inclusion).toFixed(0)}% ` : ''}
+
+
`).join('');
+
+ return `
+ ${recRows}
+ ${linksHtml ? `${linksHtml}
` : ''}`;
+ }
+
+ function renderDecksTab() {
+ const card = state.selectedCard;
+ if (!card) return '';
+
+ const name = encodeURIComponent(card.name);
+ const archidektUrl = `https://www.archidekt.com/search?q=${name}&format=commander`;
+ const moxfieldUrl = `https://www.moxfield.com/search?q=${name}`;
+ const edhrecUrl = `https://edhrec.com/cards/${name.toLowerCase().replace(/\s+/g, '-')}`;
+
+ return `
+
+
Search for decks featuring ${escapeHtml(card.name)} :
+
+
`;
+ }
+
+ function switchDrawerTab(tab) {
+ state.drawerTab = tab;
+ if (tab === 'edhrec' && !state.edhrecData && !state.edhrecLoading) {
+ loadEdhrecTab();
+ return;
+ }
+ const content = document.getElementById('coll-drawer-tab-content');
+ if (content) content.innerHTML = renderDrawerTabContent();
+ // Update tab button states
+ document.querySelectorAll('.coll-tab-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.textContent.toLowerCase().includes(tab));
+ });
+ }
+
+ async function loadEdhrecTab() {
+ if (!state.selectedCard) return;
+ state.edhrecLoading = true;
+ state.edhrecData = null;
+ const content = document.getElementById('coll-drawer-tab-content');
+ if (content) content.innerHTML = renderEdhrecTab();
+
+ try {
+ const data = await fetchEdhrecData(state.selectedCard.id);
+ state.edhrecData = data;
+ } catch (err) {
+ state.edhrecData = { error: err.message, recommendations: [], links: {} };
+ } finally {
+ state.edhrecLoading = false;
+ }
+
+ if (content) content.innerHTML = renderEdhrecTab();
+ }
+
+ // ── Drawer Editing ───────────────────────────────────────
+
+ function addDrawerCategory(cat) {
+ if (!state.selectedCard) return;
+ const categories = Array.isArray(state.selectedCard.category) ? [...state.selectedCard.category] : [];
+ if (!categories.includes(cat)) {
+ categories.push(cat);
+ state.selectedCard.category = categories;
+ refreshDrawerCategories();
+ saveCardEdits();
+ }
+ }
+
+ function removeDrawerCategory(cat) {
+ if (!state.selectedCard) return;
+ state.selectedCard.category = (state.selectedCard.category || []).filter(c => c !== cat);
+ refreshDrawerCategories();
+ saveCardEdits();
+ }
+
+ function addDrawerCategoryFromInput() {
+ const input = document.getElementById('coll-drawer-cat-input');
+ if (!input) return;
+ const val = input.value.trim();
+ if (val) {
+ addDrawerCategory(val);
+ input.value = '';
+ }
+ }
+
+ function refreshDrawerCategories() {
+ const card = state.selectedCard;
+ if (!card) return;
+ const categories = Array.isArray(card.category) ? card.category : [];
+ const chipsEl = document.getElementById('coll-drawer-cat-chips');
+ if (chipsEl) {
+ chipsEl.innerHTML = categories.map(cat =>
+ `${escapeHtml(cat)}
+ ×
+ `
+ ).join('');
+ }
+ // Refresh suggestions
+ const suggestEl = document.querySelector('.coll-cat-suggestions');
+ if (suggestEl) {
+ const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s =>
+ `${escapeHtml(s)} `
+ ).join('');
+ suggestEl.innerHTML = catSuggestions;
+ }
+ }
+
+ function addDrawerTagFromInput() {
+ const input = document.getElementById('coll-drawer-tag-input');
+ if (!input || !state.selectedCard) return;
+ const val = input.value.trim();
+ if (!val) return;
+ const tagsStr = state.selectedCard.tags || '';
+ const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
+ if (!tags.includes(val)) {
+ tags.push(val);
+ state.selectedCard.tags = tags.join(', ');
+ refreshDrawerTags();
+ saveCardEdits();
+ }
+ input.value = '';
+ }
+
+ function removeDrawerTag(tag) {
+ if (!state.selectedCard) return;
+ const tagsStr = state.selectedCard.tags || '';
+ const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t && t !== tag);
+ state.selectedCard.tags = tags.join(', ');
+ refreshDrawerTags();
+ saveCardEdits();
+ }
+
+ function refreshDrawerTags() {
+ const card = state.selectedCard;
+ if (!card) return;
+ const tags = (card.tags || '').split(',').map(t => t.trim()).filter(Boolean);
+ const tagsEl = document.getElementById('coll-drawer-tags');
+ if (tagsEl) {
+ tagsEl.innerHTML = tags.map(tag =>
+ `${escapeHtml(tag)}
+ ×
+ `
+ ).join('');
+ }
+ }
+
+ async function saveCardEdits() {
+ if (!state.selectedCard) return;
+ const card = state.selectedCard;
+ const notesEl = document.getElementById('coll-drawer-notes');
+ const finishEl = document.getElementById('coll-drawer-finish');
+ const statusEl = document.getElementById('coll-save-status');
+
+ const payload = {
+ category: card.category || [],
+ tags: card.tags || '',
+ notes: notesEl?.value ?? card.notes ?? '',
+ finish: finishEl?.value ?? card.finish ?? 'NORMAL',
+ };
+
+ if (statusEl) statusEl.textContent = 'Saving…';
+ try {
+ const updated = await patchCard(card.id, payload);
+ state.selectedCard = { ...card, ...updated };
+ if (statusEl) {
+ statusEl.textContent = 'Saved ✓';
+ statusEl.className = 'coll-save-status saved';
+ setTimeout(() => { if (statusEl) { statusEl.textContent = ''; statusEl.className = 'coll-save-status'; } }, 2000);
+ }
+ // Refresh table row category
+ refreshTableRow(card.id);
+ } catch (err) {
+ if (statusEl) {
+ statusEl.textContent = `Error: ${err.message}`;
+ statusEl.className = 'coll-save-status error';
+ }
+ }
+ }
+
+ function updateCategory(cardId, categories) {
+ const card = state.items.find(c => c.id === cardId);
+ if (card) {
+ card.category = categories;
+ patchCard(cardId, { category: categories }).catch(err =>
+ console.error('[Collection] updateCategory error:', err)
+ );
+ refreshTableRow(cardId);
+ }
+ }
+
+ function refreshTableRow(cardId) {
+ const row = document.querySelector(`.coll-row[data-id="${cardId}"]`);
+ if (!row) return;
+ const card = state.items.find(c => c.id === cardId);
+ if (!card && state.selectedCard?.id === cardId) {
+ // update from drawer
+ const updated = state.selectedCard;
+ const catTd = row.querySelector('.coll-td-category');
+ if (catTd) catTd.innerHTML = renderCategoryChips(updated.category || [], cardId);
+ return;
+ }
+ if (!card) return;
+ const catTd = row.querySelector('.coll-td-category');
+ if (catTd) catTd.innerHTML = renderCategoryChips(card.category || [], cardId);
+ }
+
+ // ── Category Popover (inline table) ──────────────────────
+
+ let popoverCardId = null;
+
+ function openCategoryPopover(cardId, chipEl) {
+ popoverCardId = cardId;
+ const card = state.items.find(c => c.id === cardId);
+ const categories = Array.isArray(card?.category) ? [...card.category] : [];
+
+ const popover = document.getElementById('coll-cat-popover');
+ if (!popover) return;
+
+ const chipRect = chipEl.getBoundingClientRect();
+ popover.style.display = 'block';
+ popover.style.top = (chipRect.bottom + window.scrollY + 4) + 'px';
+ popover.style.left = (chipRect.left + window.scrollX) + 'px';
+
+ const catChips = categories.map(cat =>
+ `${escapeHtml(cat)}
+ ×
+ `
+ ).join('');
+
+ const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s =>
+ `${escapeHtml(s)} `
+ ).join('');
+
+ popover.innerHTML = `
+
+ ${catChips}
+ ${catSuggestions}
+
+
+ Add
+
`;
+ }
+
+ function closeCategoryPopover() {
+ const popover = document.getElementById('coll-cat-popover');
+ if (popover) popover.style.display = 'none';
+ popoverCardId = null;
+ }
+
+ function popoverAddCat(cat) {
+ if (popoverCardId == null) return;
+ const card = state.items.find(c => c.id === popoverCardId);
+ if (!card) return;
+ if (!Array.isArray(card.category)) card.category = [];
+ if (!card.category.includes(cat)) {
+ card.category.push(cat);
+ updateCategory(popoverCardId, card.category);
+ openCategoryPopover(popoverCardId, document.querySelector(`.coll-row[data-id="${popoverCardId}"] .coll-cat-chip`) || document.getElementById('coll-cat-popover'));
+ }
+ }
+
+ function popoverRemoveCat(cat) {
+ if (popoverCardId == null) return;
+ const card = state.items.find(c => c.id === popoverCardId);
+ if (!card) return;
+ card.category = (card.category || []).filter(c => c !== cat);
+ updateCategory(popoverCardId, card.category);
+ // Re-render popover chips
+ const chipsEl = document.getElementById('coll-popover-chips');
+ if (chipsEl) {
+ chipsEl.innerHTML = card.category.map(c =>
+ `${escapeHtml(c)}
+ ×
+ `
+ ).join('');
+ }
+ }
+
+ function popoverAddCatFromInput() {
+ const input = document.getElementById('coll-popover-input');
+ if (!input) return;
+ const val = input.value.trim();
+ if (val) {
+ popoverAddCat(val);
+ input.value = '';
+ }
+ }
+
+ // ── Import Flow ──────────────────────────────────────────
+
+ function openImport() {
+ state.importStep = 1;
+ state.importSource = '';
+ state.importContent = '';
+ state.importMapping = {};
+ state.importHeaders = [];
+ state.importMode = 'MERGE';
+ state.importMissingFinishNormal = true;
+ const modal = document.getElementById('coll-import-modal');
+ if (modal) modal.classList.add('active');
+ renderImportStep();
+ }
+
+ function closeImport() {
+ const modal = document.getElementById('coll-import-modal');
+ if (modal) modal.classList.remove('active');
+ state.importStep = 0;
+ }
+
+ function importNext() {
+ if (state.importStep === 1) {
+ if (!state.importSource) {
+ alert('Please select a source type.');
+ return;
+ }
+ state.importStep = 2;
+ } else if (state.importStep === 2) {
+ if (!state.importContent.trim()) {
+ alert('Please provide import content.');
+ return;
+ }
+ if (state.importSource !== 'TEXT') {
+ // Parse CSV headers
+ const lines = state.importContent.split('\n');
+ if (lines.length > 0) {
+ state.importHeaders = parseCSVLine(lines[0]);
+ buildDefaultMapping();
+ }
+ state.importStep = 3;
+ } else {
+ state.importStep = 4;
+ }
+ } else if (state.importStep === 3) {
+ state.importStep = 4;
+ }
+ renderImportStep();
+ }
+
+ function importPrev() {
+ if (state.importStep > 1) {
+ if (state.importStep === 4 && state.importSource !== 'TEXT') {
+ state.importStep = 3;
+ } else if (state.importStep === 4 && state.importSource === 'TEXT') {
+ state.importStep = 2;
+ } else {
+ state.importStep--;
+ }
+ renderImportStep();
+ }
+ }
+
+ function parseCSVLine(line) {
+ const result = [];
+ let cur = '';
+ let inQuote = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (ch === '"') {
+ inQuote = !inQuote;
+ } else if (ch === ',' && !inQuote) {
+ result.push(cur.trim());
+ cur = '';
+ } else {
+ cur += ch;
+ }
+ }
+ result.push(cur.trim());
+ return result;
+ }
+
+ function buildDefaultMapping() {
+ const preset = state.importSource === 'MOXFIELD' ? MOXFIELD_DEFAULTS
+ : state.importSource === 'ARCHIDEKT' ? ARCHIDEKT_DEFAULTS
+ : {};
+ state.importMapping = {};
+ state.importHeaders.forEach(header => {
+ state.importMapping[header] = preset[header] || 'IGNORE';
+ });
+ }
+
+ function renderImportStep() {
+ const body = document.getElementById('coll-import-body');
+ if (!body) return;
+
+ const stepLabels = ['', 'Source', 'Content', 'Mapping', 'Options'];
+ const steps = [1,2,3,4].filter(n => !(n === 3 && state.importSource === 'TEXT'));
+ const stepIndicator = steps.map(n => {
+ const active = n === state.importStep ? ' active' : '';
+ const done = n < state.importStep ? ' done' : '';
+ return `${n} `;
+ }).join('— ');
+
+ let content = '';
+ if (state.importStep === 1) content = renderImportStep1();
+ if (state.importStep === 2) content = renderImportStep2();
+ if (state.importStep === 3) content = renderImportStep3();
+ if (state.importStep === 4) content = renderImportStep4();
+
+ const showPrev = state.importStep > 1;
+ const isLastStep = state.importStep === 4;
+
+ body.innerHTML = `
+
+ ${stepIndicator}
+ ${content}
+
+
`;
+ }
+
+ function renderImportStep1() {
+ const sources = [
+ { value: 'GENERIC_CSV', label: 'Generic CSV', desc: 'Any CSV with column mapping' },
+ { value: 'MOXFIELD', label: 'Moxfield CSV', desc: 'Moxfield export format (auto-mapped)' },
+ { value: 'ARCHIDEKT', label: 'Archidekt CSV',desc: 'Archidekt export format (auto-mapped)' },
+ { value: 'TEXT', label: 'Plain Text', desc: '1 Card Name (SET) per line' },
+ ];
+ return `
+ ${sources.map(s => {
+ const checked = state.importSource === s.value ? 'checked' : '';
+ return `
+
+
+ ${s.label}
+ ${s.desc}
+
+ `;
+ }).join('')}
+
`;
+ }
+
+ function renderImportStep2() {
+ if (state.importSource === 'TEXT') {
+ return `
+
Paste card list (one per line):
+
+
Formats: 3 Card Name (SET) · 1 Card Name (SET) 123 · Card Name
+
`;
+ }
+ return `
+
Upload CSV file:
+
+
+
+ ${state.importContent
+ ? `✓ File loaded (${state.importContent.split('\n').length} lines) `
+ : `Click to select or drag & drop a CSV file `}
+
+
+ ${state.importContent
+ ? `
+
Preview (first 3 lines):
+
${escapeHtml(state.importContent.split('\n').slice(0,3).join('\n'))}
+
`
+ : ''}
+
`;
+ }
+
+ function renderImportStep3() {
+ if (state.importHeaders.length === 0) {
+ return `No headers detected. Please go back and check your file.
`;
+ }
+
+ const fieldOptsHtml = FIELD_OPTIONS.map(o =>
+ `${o.label} `
+ ).join('');
+
+ const rows = state.importHeaders.map(header => {
+ const currentVal = state.importMapping[header] || 'IGNORE';
+ const opts = FIELD_OPTIONS.map(o =>
+ `${o.label} `
+ ).join('');
+ return `
+
+
+
+ ${opts}
+
+
+ `;
+ }).join('');
+
+ return `
+ Map CSV columns to collection fields. Pre-filled for ${escapeHtml(state.importSource)} format.
+
+ CSV Column Maps To
+ ${rows}
+
`;
+ }
+
+ function renderImportStep4() {
+ return `
+
+
+
+
+
+ Treat missing/unknown finish as Normal
+
+
+ ${state.importMode === 'REPLACE'
+ ? `
⚠ Replace mode will permanently delete your existing collection before importing.
`
+ : ''}
+
`;
+ }
+
+ function setImportSource(source) {
+ state.importSource = source;
+ // re-render step 1 to update selected state
+ const body = document.getElementById('coll-import-body');
+ if (body) renderImportStep();
+ }
+
+ function setImportContent(content) {
+ state.importContent = content;
+ }
+
+ function setImportMapping(header, field) {
+ state.importMapping[header] = field;
+ }
+
+ function setImportMode(mode) {
+ state.importMode = mode;
+ renderImportStep();
+ }
+
+ function setMissingFinishNormal(val) {
+ state.importMissingFinishNormal = val;
+ }
+
+ function handleFileSelect(file) {
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = e => {
+ state.importContent = e.target.result;
+ renderImportStep();
+ };
+ reader.onerror = () => alert('Failed to read file.');
+ reader.readAsText(file);
+ }
+
+ async function submitImport() {
+ const statusEl = document.getElementById('coll-import-status');
+ if (statusEl) statusEl.innerHTML = ``;
+
+ const payload = {
+ source: state.importSource,
+ mode: state.importMode,
+ content: state.importContent,
+ mapping: state.importMapping || {},
+ };
+
+ try {
+ const res = await fetch(`${API_BASE}/api/collection/import`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ let msg = data.detail || data.message || `HTTP ${res.status}`;
+ if (typeof msg === 'object') msg = JSON.stringify(msg);
+ throw new Error(msg);
+ }
+
+ const { importedCount = 0, updatedCount = 0, failedCount = 0, errors = [] } = data;
+
+ let html = `
+
✓ Import complete
+
${importedCount} added · ${updatedCount} updated · ${failedCount} failed
+
`;
+
+ if (errors.length > 0) {
+ html += `
+
Errors (${errors.length}):
+
${errors.slice(0, 10).map(e => `${escapeHtml(e)} `).join('')}
+ ${errors.length > 10 ? `
…and ${errors.length - 10} more
` : ''}
+
`;
+ }
+
+ html += `Close `;
+ if (statusEl) statusEl.innerHTML = html;
+
+ // Hide footer nav buttons
+ const footer = document.querySelector('.coll-import-footer');
+ if (footer) footer.style.display = 'none';
+
+ // Refresh collection
+ applyColumnVisibility();
+ await fetchCollection();
+
+ } catch (err) {
+ if (statusEl) {
+ const msg = (err && err.message) ? err.message : String(err);
+ statusEl.innerHTML = `✗ Import failed: ${escapeHtml(msg)}
`;
+ }
+ }
+ }
+
+ // ── Export Flow ──────────────────────────────────────────
+
+ function openExport() {
+ state.exportOpen = true;
+ const modal = document.getElementById('coll-export-modal');
+ if (modal) modal.classList.add('active');
+ renderExportModal();
+ }
+
+ function closeExport() {
+ state.exportOpen = false;
+ const modal = document.getElementById('coll-export-modal');
+ if (modal) modal.classList.remove('active');
+ }
+
+ function renderExportModal() {
+ const body = document.getElementById('coll-export-body');
+ if (!body) return;
+ body.innerHTML = `
+
+
+ `;
+ }
+
+ function submitExport() {
+ const formatEl = document.getElementById('coll-export-format');
+ const filteredEl = document.getElementById('coll-export-filtered');
+ const format = formatEl?.value || 'INTERNAL_CSV';
+ const useFilters = filteredEl?.checked || false;
+
+ const params = new URLSearchParams();
+ params.set('format', format);
+
+ if (useFilters) {
+ const f = state.filters;
+ if (state.search) params.set('q', state.search);
+ if (f.colors.length) params.set('colors', f.colors.join(','));
+ if (f.types.length) params.set('types', f.types.join(','));
+ if (f.finish) params.set('finish', f.finish);
+ if (f.category.length) params.set('category', f.category.join(','));
+ if (f.isLegendary) params.set('isLegendary', 'true');
+ if (f.isBasic) params.set('isBasic', 'true');
+ if (f.isGameChanger) params.set('isGameChanger', 'true');
+ if (f.highSalt) params.set('highSalt', 'true');
+ if (f.cmcMin != null) params.set('cmcMin', f.cmcMin);
+ if (f.cmcMax != null) params.set('cmcMax', f.cmcMax);
+ if (f.priceMin != null) params.set('priceMin', f.priceMin);
+ if (f.priceMax != null) params.set('priceMax', f.priceMax);
+ }
+
+ const url = `${API_BASE}/api/collection/export?${params.toString()}`;
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `collection_${format.toLowerCase()}_${new Date().toISOString().slice(0,10)}.${format === 'TEXT' ? 'txt' : 'csv'}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ closeExport();
+ }
+
+ // ── Re-enrich ────────────────────────────────────────────
+
+ async function reEnrich() {
+ const btn = document.querySelector('.coll-reenrich-btn');
+ if (!btn) return;
+ const originalText = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = 'Refreshing...';
+
+ try {
+ const res = await fetch(`${API_BASE}/api/collection/re-enrich`, { method: 'POST' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ const msg = `Refreshed ${data.enrichedCount} of ${data.total} cards`
+ + (data.skippedCount > 0 ? ` (${data.skippedCount} skipped)` : '');
+ alert(msg);
+ fetchCollection(); // reload table
+ } catch (err) {
+ alert(`Re-enrich failed: ${err.message}`);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
+ }
+
+ // ── Auto-Classify ────────────────────────────────────────
+
+ async function autoClassify(forceAll = false) {
+ const btn = document.querySelector('.coll-autoclassify-btn');
+ if (!btn) return;
+ const originalText = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = 'Classifying...';
+
+ try {
+ const endpoint = forceAll
+ ? `${API_BASE}/api/collection/auto-classify-all`
+ : `${API_BASE}/api/collection/auto-classify`;
+ const res = await fetch(endpoint, { method: 'POST' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ const msg = `Classified ${data.classifiedCount} of ${data.total} cards`
+ + (data.skippedCount > 0 ? ` (${data.skippedCount} already had categories)` : '');
+ alert(msg);
+ fetchCollection(); // reload table
+ } catch (err) {
+ alert(`Auto-classify failed: ${err.message}`);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = originalText;
+ }
+ }
+
+ // ── Navigation ───────────────────────────────────────────
+
+ function goBack() {
+ window.location.href = '/index.html';
+ }
+
+ // ── Bulk Row Selection & Quantity Edit ─────────────────────
+ function toggleRowSelect(cardId, checked) {
+ if (checked) {
+ state.selectedRows.add(cardId);
+ } else {
+ state.selectedRows.delete(cardId);
+ }
+ updateBulkBar();
+ }
+
+ function toggleSelectAll(checked) {
+ if (checked) {
+ state.items.forEach(c => state.selectedRows.add(c.id));
+ } else {
+ state.selectedRows.clear();
+ }
+ // Update all checkboxes
+ document.querySelectorAll('.coll-row-check').forEach(cb => {
+ cb.checked = checked;
+ });
+ updateBulkBar();
+ }
+
+ function selectAllRows() {
+ state.items.forEach(c => state.selectedRows.add(c.id));
+ document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = true);
+ const selectAllCb = document.getElementById('coll-select-all');
+ if (selectAllCb) selectAllCb.checked = true;
+ updateBulkBar();
+ }
+
+ function clearSelection() {
+ state.selectedRows.clear();
+ document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = false);
+ const selectAllCb = document.getElementById('coll-select-all');
+ if (selectAllCb) selectAllCb.checked = false;
+ updateBulkBar();
+ }
+
+ function updateBulkBar() {
+ const bar = document.getElementById('coll-bulk-bar');
+ const countEl = document.getElementById('coll-bulk-count');
+ const n = state.selectedRows.size;
+ if (bar) bar.style.display = n > 0 ? 'flex' : 'none';
+ if (countEl) countEl.textContent = `${n} selected`;
+ }
+
+ async function bulkAdjustQuantity(delta) {
+ if (state.selectedRows.size === 0) return;
+ const ids = [...state.selectedRows];
+ const btn = event.target;
+ if (btn) btn.disabled = true;
+ let successCount = 0;
+ for (const id of ids) {
+ const card = state.items.find(c => c.id === id);
+ if (!card) continue;
+ const newQty = Math.max(0, (card.quantity ?? 1) + delta);
+ try {
+ await patchCard(id, { quantity: newQty });
+ card.quantity = newQty;
+ successCount++;
+ } catch (e) {
+ console.warn('[Bulk] Failed to update card', id, e);
+ }
+ }
+ if (btn) btn.disabled = false;
+ renderTable();
+ applyColumnVisibility();
+ }
+
+ async function bulkSetQuantity() {
+ if (state.selectedRows.size === 0) return;
+ const input = document.getElementById('coll-bulk-qty-input');
+ const newQty = input ? Math.max(0, parseInt(input.value, 10) || 0) : 0;
+ const ids = [...state.selectedRows];
+ const btn = document.querySelector('.coll-bulk-set-btn');
+ if (btn) btn.disabled = true;
+ for (const id of ids) {
+ const card = state.items.find(c => c.id === id);
+ if (!card) continue;
+ try {
+ await patchCard(id, { quantity: newQty });
+ card.quantity = newQty;
+ } catch (e) {
+ console.warn('[Bulk] Failed to set card', id, e);
+ }
+ }
+ if (btn) btn.disabled = false;
+ renderTable();
+ applyColumnVisibility();
+ }
+
+ // ── Event Binding ────────────────────────────────────────
+
+ function bindEvents() {
+ // Search input with debounce
+ const searchEl = document.getElementById('coll-search');
+ if (searchEl) {
+ searchEl.addEventListener('input', e => {
+ clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = setTimeout(() => {
+ state.search = e.target.value.trim();
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }, DEBOUNCE_MS);
+ });
+ searchEl.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ searchEl.value = '';
+ state.search = '';
+ state.page = 1;
+ pushUrlState();
+ fetchCollection();
+ }
+ });
+ }
+
+ // Close drawer on Escape
+ document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ if (state.drawerOpen) closeDrawer();
+ if (state.importStep > 0) closeImport();
+ if (state.exportOpen) closeExport();
+ closeCategoryPopover();
+ }
+ });
+
+ // Close popover on outside click
+ document.addEventListener('click', e => {
+ const popover = document.getElementById('coll-cat-popover');
+ if (popover && popover.style.display !== 'none') {
+ if (!popover.contains(e.target) && !e.target.classList.contains('coll-cat-chip')) {
+ closeCategoryPopover();
+ }
+ }
+ // Close set/keyword dropdowns on outside click
+ const setDD = document.getElementById('coll-set-dropdown');
+ const setInput = document.getElementById('coll-set-input');
+ if (setDD && setDD.style.display !== 'none' && !setDD.contains(e.target) && e.target !== setInput) {
+ setDD.innerHTML = ''; setDD.style.display = 'none';
+ }
+ const kwDD = document.getElementById('coll-kw-dropdown');
+ const kwInput = document.getElementById('coll-kw-input');
+ if (kwDD && kwDD.style.display !== 'none' && !kwDD.contains(e.target) && e.target !== kwInput) {
+ kwDD.innerHTML = ''; kwDD.style.display = 'none';
+ }
+ });
+
+ // File drop zone
+ const dropZone = document.getElementById('coll-file-drop');
+ if (dropZone) {
+ dropZone.addEventListener('dragover', e => {
+ e.preventDefault();
+ dropZone.classList.add('drag-over');
+ });
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
+ dropZone.addEventListener('drop', e => {
+ e.preventDefault();
+ dropZone.classList.remove('drag-over');
+ const file = e.dataTransfer.files[0];
+ if (file) handleFileSelect(file);
+ });
+ }
+ }
+
+ // ── Card Scanner ─────────────────────────────────────────
+
+ let scanState = {
+ file: null,
+ previewUrl: null,
+ mode: 'single',
+ scanning: false,
+ results: [], // ScanResult dicts from API
+ selected: new Set(), // indices of results selected for add
+ };
+
+ function openScan() {
+ scanState = { file: null, previewUrl: null, mode: 'single',
+ scanning: false, results: [], selected: new Set(),
+ manualCards: [] };
+ const modal = document.getElementById('coll-scan-modal');
+ if (modal) modal.classList.add('active');
+ renderScanModal();
+ }
+
+ function closeScan() {
+ const modal = document.getElementById('coll-scan-modal');
+ if (modal) modal.classList.remove('active');
+ // Revoke object URL to free memory
+ if (scanState.previewUrl) {
+ URL.revokeObjectURL(scanState.previewUrl);
+ scanState.previewUrl = null;
+ }
+ }
+
+ function renderScanModal() {
+ const body = document.getElementById('coll-scan-body');
+ if (!body) return;
+
+ const hasResults = scanState.results.length > 0;
+ const hasFile = scanState.file !== null;
+
+ let content = '';
+
+ if (hasResults) {
+ content = renderScanResults();
+ } else {
+ content = renderScanUpload();
+ }
+
+ body.innerHTML = `
+
+ ${content}
+
`;
+ }
+
+ function renderScanUpload() {
+ const preview = scanState.previewUrl
+ ? ' '
+ : '';
+
+ const singleChk = scanState.mode === 'single' ? 'checked' : '';
+ const multiChk = scanState.mode === 'multi' ? 'checked' : '';
+ const scanDisabled = scanState.file ? '' : 'disabled';
+ const scanningCls = scanState.scanning ? ' scanning' : '';
+
+ return `
+
+
+ ${preview || '
Drop card image here or click to browse
'}
+
+
+
+
+ `;
+ }
+
+ function renderScanResults() {
+ const results = scanState.results;
+ const selectedCount = scanState.selected.size;
+ const manualCount = scanState.manualCards ? scanState.manualCards.length : 0;
+ const totalAdd = selectedCount + manualCount;
+
+ const rows = results.map((r, i) => {
+ const checked = scanState.selected.has(i) ? 'checked' : '';
+ const confCls = 'coll-scan-conf-' + (r.confidence || 'low');
+ const confLabel = (r.confidence || 'low').charAt(0).toUpperCase() + (r.confidence || 'low').slice(1);
+
+ if (r.error && !r.matched_name) {
+ return `
+
+
${escapeHtml(r.error)}
+
+
+ Look up
+
+
+
`;
+ }
+
+ const imgHtml = r.image_uri
+ ? ' '
+ : 'No image
';
+
+ return `
+
+
+
+ ${imgHtml}
+
+
${escapeHtml(r.matched_name || r.raw_ocr)}
+
+ ${confLabel}
+ ${r.set_code ? ' · ' + escapeHtml(r.set_code.toUpperCase()) : ''}
+ ${r.collector_number ? ' #' + escapeHtml(r.collector_number) : ''}
+ ${r.rarity ? ' · ' + escapeHtml(r.rarity.charAt(0).toUpperCase() + r.rarity.slice(1)) : ''}
+
+
+
+ Qty
+
+
+
`;
+ }).join('');
+
+ // Manual cards added via name lookup
+ const manualRows = (scanState.manualCards || []).map((mc, mi) => {
+ return `
+
+
+
+ ${mc.image_uri
+ ? '
'
+ : '
No image
'}
+
+
${escapeHtml(mc.name)}
+
+ Manual
+ ${mc.set_code ? ' · ' + escapeHtml(mc.set_code.toUpperCase()) : ''}
+
+
+
+ Qty
+
+
+
\u2715
+
`;
+ }).join('');
+
+ return `
+
+
+
+ ${rows}
+ ${manualRows}
+
+
+
+ Add
+
+
+ `;
+ }
+
+ function handleScanFile(input) {
+ const file = input.files && input.files[0];
+ if (!file) return;
+ _setScanFile(file);
+ }
+
+ function _setScanFile(file) {
+ if (scanState.previewUrl) URL.revokeObjectURL(scanState.previewUrl);
+ scanState.file = file;
+ scanState.previewUrl = URL.createObjectURL(file);
+ scanState.results = [];
+ scanState.selected = new Set();
+ renderScanModal();
+ }
+
+ function setScanMode(mode) {
+ scanState.mode = mode;
+ }
+
+ async function submitScan() {
+ if (!scanState.file || scanState.scanning) return;
+ scanState.scanning = true;
+ renderScanModal();
+
+ const formData = new FormData();
+ formData.append('file', scanState.file);
+ formData.append('mode', scanState.mode);
+
+ try {
+ const resp = await fetch(API_BASE + '/api/collection/scan', {
+ method: 'POST',
+ body: formData,
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
+ throw new Error(err.detail || resp.statusText);
+ }
+ const data = await resp.json();
+ scanState.results = data.results || [];
+ // Auto-select all high/medium confidence matches
+ scanState.selected = new Set();
+ scanState.results.forEach((r, i) => {
+ if (r.matched_name && (r.confidence === 'high' || r.confidence === 'medium')) {
+ scanState.selected.add(i);
+ }
+ });
+ } catch (e) {
+ const statusEl = document.getElementById('coll-scan-status');
+ if (statusEl) statusEl.innerHTML = 'Scan failed: ' + escapeHtml(e.message) + '
';
+ } finally {
+ scanState.scanning = false;
+ renderScanModal();
+ }
+ }
+
+ function toggleScanResult(idx) {
+ if (scanState.selected.has(idx)) {
+ scanState.selected.delete(idx);
+ } else {
+ scanState.selected.add(idx);
+ }
+ renderScanModal();
+ }
+
+ function scanSelectAll() {
+ scanState.results.forEach((r, i) => {
+ if (r.matched_name) scanState.selected.add(i);
+ });
+ renderScanModal();
+ }
+
+ function scanDeselectAll() {
+ scanState.selected.clear();
+ renderScanModal();
+ }
+
+ function scanBack() {
+ scanState.results = [];
+ scanState.selected = new Set();
+ scanState.manualCards = [];
+ renderScanModal();
+ }
+
+ async function scanManualLookup(errIdx) {
+ const input = document.querySelector('input.coll-scan-manual-input[data-err-idx="' + errIdx + '"]');
+ if (!input || !input.value.trim()) return;
+ const name = input.value.trim();
+ input.disabled = true;
+
+ try {
+ const lookupResp = await fetch('https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name));
+ if (!lookupResp.ok) {
+ const err = await lookupResp.json().catch(() => ({}));
+ throw new Error(err.details || 'Card not found on Scryfall');
+ }
+ const card = await lookupResp.json();
+ const imageUris = card.image_uris || (card.card_faces && card.card_faces[0] ? card.card_faces[0].image_uris : {}) || {};
+
+ // Replace the error result with a matched result
+ scanState.results[errIdx] = {
+ raw_ocr: name,
+ matched_name: card.name,
+ set_code: card.set || '',
+ scryfall_id: card.id || '',
+ confidence: 'high',
+ image_uri: imageUris.normal || imageUris.small || '',
+ error: '',
+ };
+ scanState.selected.add(errIdx);
+ renderScanModal();
+ } catch (e) {
+ input.disabled = false;
+ input.style.borderColor = 'var(--lab-danger, #f85149)';
+ input.placeholder = e.message || 'Not found, try again';
+ input.value = '';
+ }
+ }
+
+ async function scanManualAdd() {
+ const input = document.getElementById('coll-scan-manual-name');
+ if (!input || !input.value.trim()) return;
+ const name = input.value.trim();
+ input.disabled = true;
+
+ try {
+ const lookupResp = await fetch('https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name));
+ if (!lookupResp.ok) {
+ const err = await lookupResp.json().catch(() => ({}));
+ throw new Error(err.details || 'Card not found on Scryfall');
+ }
+ const card = await lookupResp.json();
+ const imageUris = card.image_uris || (card.card_faces && card.card_faces[0] ? card.card_faces[0].image_uris : {}) || {};
+
+ if (!scanState.manualCards) scanState.manualCards = [];
+ scanState.manualCards.push({
+ name: card.name,
+ set_code: card.set || '',
+ image_uri: imageUris.normal || imageUris.small || '',
+ });
+ renderScanModal();
+ } catch (e) {
+ input.disabled = false;
+ input.style.borderColor = 'var(--lab-danger, #f85149)';
+ input.placeholder = e.message || 'Not found, try again';
+ input.value = '';
+ }
+ }
+
+ function scanRemoveManual(idx) {
+ if (scanState.manualCards) {
+ scanState.manualCards.splice(idx, 1);
+ renderScanModal();
+ }
+ }
+
+ async function submitScanAdd() {
+ const cards = [];
+ // OCR-matched cards
+ scanState.selected.forEach(idx => {
+ const r = scanState.results[idx];
+ if (!r || !r.matched_name) return;
+ const qtyInput = document.querySelector('input.coll-scan-qty-input[data-scan-idx="' + idx + '"]');
+ const qty = qtyInput ? parseInt(qtyInput.value, 10) || 1 : 1;
+ cards.push({
+ name: r.matched_name,
+ set_code: r.set_code || '',
+ collector_number: r.collector_number || '',
+ quantity: qty,
+ });
+ });
+ // Manual cards
+ (scanState.manualCards || []).forEach((mc, mi) => {
+ const qtyInput = document.querySelector('input.coll-scan-manual-qty[data-manual-idx="' + mi + '"]');
+ const qty = qtyInput ? parseInt(qtyInput.value, 10) || 1 : 1;
+ cards.push({
+ name: mc.name,
+ set_code: mc.set_code || '',
+ quantity: qty,
+ });
+ });
+
+ if (cards.length === 0) return;
+
+ const statusEl = document.getElementById('coll-scan-status');
+ if (statusEl) statusEl.innerHTML = 'Adding cards...
';
+
+ try {
+ const resp = await fetch(API_BASE + '/api/collection/scan/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cards }),
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
+ throw new Error(err.detail || resp.statusText);
+ }
+ const data = await resp.json();
+ let msg = '';
+ if (data.importedCount) msg += data.importedCount + ' added';
+ if (data.updatedCount) msg += (msg ? ', ' : '') + data.updatedCount + ' updated';
+ if (data.failedCount) msg += (msg ? ', ' : '') + data.failedCount + ' failed';
+ if (statusEl) statusEl.innerHTML = '' + escapeHtml(msg || 'Done') + '
';
+
+ // Refresh collection after short delay
+ setTimeout(() => {
+ closeScan();
+ fetchCollection();
+ }, 1500);
+ } catch (e) {
+ if (statusEl) statusEl.innerHTML = 'Failed: ' + escapeHtml(e.message) + '
';
+ }
+ }
+
+ // Bind scan dropzone click + drag events after render
+ function _bindScanDropzone() {
+ const dropzone = document.getElementById('coll-scan-dropzone');
+ if (!dropzone) return;
+ dropzone.addEventListener('click', () => {
+ const input = document.getElementById('coll-scan-file');
+ if (input) input.click();
+ });
+ dropzone.addEventListener('dragover', e => {
+ e.preventDefault();
+ dropzone.classList.add('drag-over');
+ });
+ dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
+ dropzone.addEventListener('drop', e => {
+ e.preventDefault();
+ dropzone.classList.remove('drag-over');
+ const file = e.dataTransfer.files[0];
+ if (file) _setScanFile(file);
+ });
+ }
+
+ // Patch renderScanModal to rebind dropzone after DOM update
+ const _origRenderScanModal = renderScanModal;
+ renderScanModal = function() {
+ _origRenderScanModal();
+ setTimeout(_bindScanDropzone, 0);
+ };
+
+ // ── Public API ───────────────────────────────────────────
+ return {
+ init,
+ goBack,
+ // Import
+ openImport,
+ closeImport,
+ importNext,
+ importPrev,
+ submitImport,
+ setImportSource,
+ setImportContent,
+ setImportMapping,
+ setImportMode,
+ setMissingFinishNormal,
+ handleFileSelect,
+ // Export
+ openExport,
+ closeExport,
+ submitExport,
+ // Scan
+ openScan,
+ closeScan,
+ handleScanFile,
+ setScanMode,
+ submitScan,
+ toggleScanResult,
+ scanSelectAll,
+ scanDeselectAll,
+ scanBack,
+ submitScanAdd,
+ scanManualLookup,
+ scanManualAdd,
+ scanRemoveManual,
+ // Drawer
+ openDrawer,
+ closeDrawer,
+ switchDrawerTab,
+ loadEdhrecTab,
+ saveCardEdits,
+ addDrawerCategory,
+ removeDrawerCategory,
+ addDrawerCategoryFromInput,
+ addDrawerTagFromInput,
+ removeDrawerTag,
+ // Filters
+ toggleColor,
+ toggleType,
+ toggleFilter,
+ setFinish,
+ setCmcRange,
+ setPriceRange,
+ toggleCategoryFilter,
+ toggleRarity,
+ setPowerRange,
+ setToughRange,
+ setEdhrecRange,
+ setQtyRange,
+ onSetInput,
+ addSetFilter,
+ removeSetFilter,
+ onKwInput,
+ addKeywordFilter,
+ removeKeywordFilter,
+ resetFilters,
+ // Sort / page
+ setSort,
+ setSortFromBar,
+ toggleSortDir,
+ setPage,
+ setPageSize,
+ // Category inline
+ updateCategory,
+ openCategoryPopover,
+ closeCategoryPopover,
+ popoverAddCat,
+ popoverRemoveCat,
+ popoverAddCatFromInput,
+ reEnrich,
+ autoClassify,
+ toggleStats,
+ // Column visibility
+ toggleColumn,
+ openColumnPanel,
+ closeColumnPanel,
+ resetColumns,
+ // Bulk selection & quantity
+ toggleRowSelect,
+ toggleSelectAll,
+ selectAllRows,
+ clearSelection,
+ bulkAdjustQuantity,
+ bulkSetQuantity,
+ };
+})();
diff --git a/lan-client/deckbuilder.css b/lan-client/deckbuilder.css
new file mode 100644
index 0000000..223f671
--- /dev/null
+++ b/lan-client/deckbuilder.css
@@ -0,0 +1,3286 @@
+/* ═══════════════════════════════════════════════════════════
+ Commander AI Lab — Deck Builder Styles
+ Uses deck- prefix to avoid conflicts with lab- / coll- classes
+ Tokens from global.css design system
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── JS Backward-Compat Aliases ─────────────────────────
+ deckbuilder.js uses var(--lab-danger) in template literals.
+ Map it to the global token so inline styles work.
+ ──────────────────────────────────────────────────────── */
+:root {
+ --lab-danger: var(--error);
+}
+
+/* ── Z-INDEX STACK ────────────────────────────────────────
+ 10 sticky section headers
+ 50 quickadd dropdown
+ 60 card preview tooltip
+ 100 topbar
+ 500 toast container
+ 1000 modal overlays
+ ──────────────────────────────────────────────────────── */
+
+/* ── 1. Page Base ───────────────────────────────────────── */
+
+#deck-builder-page {
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ flex-direction: column;
+ isolation: isolate;
+}
+
+/* ── 2. Top Bar ─────────────────────────────────────────── */
+
+.deck-topbar {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-4);
+ position: sticky;
+ top: var(--nav-height);
+ background: rgba(12, 14, 20, 0.97);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-bottom: 1px solid var(--border);
+ z-index: 100;
+ padding: var(--sp-3) var(--sp-5);
+ min-height: 60px;
+}
+
+.deck-topbar-commander {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ flex-shrink: 0;
+ min-width: 0;
+}
+
+.deck-commander-portrait {
+ width: 44px;
+ height: 60px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border);
+ background: var(--surface-1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ flex-shrink: 0;
+ position: relative;
+ cursor: pointer;
+ transition: border-color var(--duration) var(--ease);
+}
+
+.deck-commander-portrait:hover {
+ border-color: var(--accent);
+}
+
+.deck-commander-portrait-placeholder {
+ font-size: 20px;
+ opacity: 0.4;
+ user-select: none;
+}
+
+.deck-commander-portrait-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.deck-name-block {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+.deck-name-input {
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid transparent;
+ color: #fff;
+ font-size: 16px;
+ font-weight: 600;
+ font-family: inherit;
+ padding: 2px 4px 2px 0;
+ outline: none;
+ width: 200px;
+ transition: border-color var(--duration) var(--ease);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.deck-name-input:hover {
+ border-bottom-color: var(--border);
+}
+
+.deck-name-input:focus {
+ border-bottom-color: var(--accent);
+}
+
+.deck-name-input::placeholder {
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+.deck-commander-label {
+ font-size: 11px;
+ color: var(--accent);
+ padding-left: var(--sp-1);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 200px;
+ cursor: pointer;
+ transition: color 0.1s;
+}
+
+.deck-commander-label:hover {
+ color: var(--text);
+}
+
+.deck-color-chips {
+ display: flex;
+ gap: var(--sp-1);
+ flex-shrink: 0;
+}
+
+/* MTG mana color chips — domain-specific colors */
+.deck-color-chip {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ font-weight: 800;
+ line-height: 1;
+ box-shadow: var(--shadow-sm);
+ border: 1.5px solid rgba(0, 0, 0, 0.3);
+ user-select: none;
+}
+
+.deck-chip-W { background: #F9FAF4; color: #333; }
+.deck-chip-U { background: #0E68AB; color: #fff; }
+.deck-chip-B { background: #3a2a1a; color: #c9a060; border-color: #5a3a1a; }
+.deck-chip-R { background: #D3202A; color: #fff; }
+.deck-chip-G { background: #00733E; color: #fff; }
+.deck-chip-C { background: #9a9a9a; color: #fff; }
+
+.deck-topbar-center {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ flex-grow: 1;
+ justify-content: center;
+ min-width: 0;
+}
+
+.deck-count-display {
+ display: flex;
+ align-items: baseline;
+ gap: 3px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.deck-count-num {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text);
+ line-height: 1;
+}
+
+.deck-count-num.over-limit {
+ color: var(--error);
+}
+
+.deck-count-num.at-limit {
+ color: var(--success);
+}
+
+.deck-count-sep {
+ font-size: 14px;
+}
+
+.deck-count-target {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.deck-count-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.deck-selector {
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px var(--sp-3);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ cursor: pointer;
+ max-width: 200px;
+ transition: border-color var(--duration) var(--ease);
+}
+
+.deck-selector:hover,
+.deck-selector:focus {
+ border-color: var(--accent);
+}
+
+.deck-selector-wrap {
+ flex-shrink: 0;
+}
+
+.deck-nav-links {
+ display: flex;
+ gap: var(--sp-2);
+ flex-shrink: 0;
+}
+
+.deck-nav-link {
+ color: var(--text-secondary);
+ font-size: 12px;
+ text-decoration: none;
+ padding: 5px var(--sp-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-1);
+ transition: all var(--duration) var(--ease);
+ white-space: nowrap;
+}
+
+.deck-nav-link:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.deck-topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ flex-shrink: 0;
+}
+
+/* ── 3. Shared Button Styles ────────────────────────────── */
+
+.deck-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 7px 14px;
+ border-radius: var(--radius-md);
+ font-size: 13px;
+ font-family: inherit;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid var(--border);
+ transition: all var(--duration) var(--ease);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.deck-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.deck-btn-ghost {
+ background: var(--surface-1);
+ color: var(--text);
+ border-color: var(--border);
+}
+
+.deck-btn-ghost:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.deck-btn-accent {
+ background: var(--accent-dim);
+ color: var(--accent);
+ border-color: var(--accent-glow);
+}
+
+.deck-btn-accent:hover:not(:disabled) {
+ background: rgba(91, 158, 240, 0.28);
+ border-color: var(--accent);
+}
+
+.deck-btn-accent-sm {
+ background: var(--accent-dim);
+ color: var(--accent);
+ border-color: var(--accent-glow);
+ padding: 5px var(--sp-3);
+ font-size: 12px;
+}
+
+.deck-btn-accent-sm:hover:not(:disabled) {
+ background: rgba(91, 158, 240, 0.28);
+ border-color: var(--accent);
+}
+
+.deck-btn-success {
+ background: var(--success-dim);
+ color: var(--success);
+ border-color: rgba(74, 222, 128, 0.4);
+}
+
+.deck-btn-success:hover:not(:disabled) {
+ background: rgba(74, 222, 128, 0.25);
+ border-color: var(--success);
+}
+
+.deck-btn-danger {
+ background: var(--error-dim);
+ color: var(--error);
+ border-color: rgba(248, 113, 113, 0.3);
+}
+
+.deck-btn-danger:hover:not(:disabled) {
+ background: rgba(248, 113, 113, 0.22);
+ border-color: var(--error);
+}
+
+.deck-btn-import {
+ background: var(--success-dim);
+ color: var(--success);
+ border-color: rgba(74, 222, 128, 0.4);
+ font-weight: 600;
+}
+
+.deck-btn-import:hover:not(:disabled) {
+ background: rgba(74, 222, 128, 0.3);
+ border-color: var(--success);
+ box-shadow: 0 0 10px var(--success-dim);
+}
+
+.deck-btn-sim {
+ background: linear-gradient(135deg, var(--accent-glow), var(--purple-dim));
+ color: var(--accent);
+ border-color: rgba(91, 158, 240, 0.5);
+ font-weight: 600;
+}
+
+.deck-btn-sim:hover:not(:disabled) {
+ background: linear-gradient(135deg, rgba(91, 158, 240, 0.38), rgba(167, 139, 250, 0.3));
+ border-color: var(--accent);
+ box-shadow: 0 0 12px var(--accent-glow);
+}
+
+.deck-btn-danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ border-color: rgba(239, 68, 68, 0.4);
+}
+.deck-btn-danger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.3);
+ border-color: #ef4444;
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.2);
+}
+
+.deck-btn-danger-outline {
+ background: transparent;
+ color: rgba(239, 68, 68, 0.7);
+ border-color: rgba(239, 68, 68, 0.3);
+ font-size: 0.72rem;
+}
+.deck-btn-danger-outline:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.12);
+ color: #ef4444;
+ border-color: rgba(239, 68, 68, 0.5);
+}
+
+/* Import modal */
+.deck-import-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: var(--sp-3);
+ border-bottom: 1px solid var(--border);
+}
+
+.deck-import-tab {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 13px;
+ padding: var(--sp-2) var(--sp-4);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all var(--duration) var(--ease);
+}
+
+.deck-import-tab:hover { color: var(--text); }
+
+.deck-import-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+#deck-import-textarea {
+ width: 100%;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: var(--sp-3);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ resize: vertical;
+ outline: none;
+}
+
+#deck-import-textarea:focus { border-color: var(--accent); }
+
+.deck-import-dropzone {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-8) var(--sp-5);
+ text-align: center;
+ cursor: pointer;
+ transition: all var(--duration) var(--ease);
+}
+
+.deck-import-dropzone:hover,
+.deck-import-dropzone.dragover {
+ border-color: var(--accent);
+ background: var(--accent-dim);
+}
+
+.deck-import-dropzone-label {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.deck-import-options {
+ display: flex;
+ gap: var(--sp-4);
+ margin-top: var(--sp-3);
+ flex-wrap: wrap;
+}
+
+.deck-import-option-label {
+ font-size: 13px;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ cursor: pointer;
+}
+
+.deck-import-option-label input[type="radio"] {
+ accent-color: var(--accent);
+}
+
+.deck-import-name-row {
+ margin-top: var(--sp-3);
+}
+
+.deck-import-name-row label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ display: block;
+ margin-bottom: var(--sp-1);
+}
+
+.deck-import-name-row input {
+ width: 100%;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: var(--sp-2) var(--sp-3);
+ border-radius: var(--radius-sm);
+ font-size: 14px;
+ outline: none;
+}
+
+.deck-import-name-row input:focus { border-color: var(--accent); }
+
+/* Import progress (moved from inline styles) */
+.deck-import-progress-wrap {
+ margin-top: var(--sp-3);
+ padding: var(--sp-3);
+ background: var(--surface-0);
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-subtle);
+}
+
+.deck-import-progress-label {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: var(--sp-2);
+}
+
+.deck-import-progress-track {
+ background: var(--surface-3);
+ border-radius: var(--radius-sm);
+ height: 6px;
+ overflow: hidden;
+}
+
+.deck-import-progress-fill {
+ background: var(--accent);
+ height: 100%;
+ width: 0%;
+ border-radius: var(--radius-sm);
+ transition: width 0.3s var(--ease);
+}
+
+/* Import file info (moved from inline style) */
+.deck-import-file-info {
+ margin-top: var(--sp-2);
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.deck-btn-icon {
+ font-size: 11px;
+}
+
+.deck-btn-full {
+ width: 100%;
+ justify-content: center;
+}
+
+.deck-btn-sm {
+ padding: 4px var(--sp-3);
+ font-size: 12px;
+}
+
+.deck-btn-xs {
+ padding: 3px var(--sp-2);
+ font-size: 11px;
+ border-radius: 5px;
+}
+
+/* ── 4. Main 3-Column Layout ────────────────────────────── */
+
+.deck-main {
+ display: flex;
+ flex-direction: row;
+ gap: 0;
+ flex: 1;
+ min-height: 0;
+ align-items: flex-start;
+}
+
+/* LEFT COLUMN */
+.deck-col-left {
+ width: 300px;
+ flex-shrink: 0;
+ position: sticky;
+ top: calc(var(--nav-height) + 60px);
+ height: calc(100vh - var(--nav-height) - 60px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ border-right: 1px solid var(--border);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.deck-col-left::-webkit-scrollbar { width: 6px; }
+.deck-col-left::-webkit-scrollbar-track { background: transparent; }
+.deck-col-left::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.deck-col-left::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
+
+/* CENTER COLUMN */
+.deck-col-center {
+ flex: 1;
+ min-width: 0;
+ min-height: calc(100vh - var(--nav-height) - 60px);
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.deck-col-center::-webkit-scrollbar { width: 6px; }
+.deck-col-center::-webkit-scrollbar-track { background: transparent; }
+.deck-col-center::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+/* RIGHT COLUMN */
+.deck-col-right {
+ width: 360px;
+ flex-shrink: 0;
+ position: sticky;
+ top: calc(var(--nav-height) + 60px);
+ height: calc(100vh - var(--nav-height) - 60px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ border-left: 1px solid var(--border);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.deck-col-right::-webkit-scrollbar { width: 6px; }
+.deck-col-right::-webkit-scrollbar-track { background: transparent; }
+.deck-col-right::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+.deck-col-right::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
+
+/* ── 5. Panels ──────────────────────────────────────────── */
+
+.deck-panel {
+ background: var(--bg);
+}
+
+.deck-panel + .deck-panel {
+ border-top: 1px solid var(--border);
+}
+
+.deck-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.deck-panel-header-collapsible {
+ cursor: pointer;
+ user-select: none;
+ transition: background var(--duration) var(--ease);
+}
+
+.deck-panel-header-collapsible:hover {
+ background: var(--accent-dim);
+}
+
+.deck-panel-title {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text);
+}
+
+.deck-collapse-arrow {
+ font-size: 10px;
+ color: var(--text-secondary);
+ transition: transform 0.2s var(--ease);
+}
+
+.deck-panel-body {
+ padding: var(--sp-3) var(--sp-4);
+ overflow: hidden;
+ transition: max-height 0.25s ease, padding 0.25s ease;
+}
+
+.deck-panel-body.collapsed {
+ max-height: 0 !important;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.deck-collapse-arrow.collapsed {
+ transform: rotate(-90deg);
+}
+
+/* ── 6. Left Column: Search Panel ───────────────────────── */
+
+.deck-panel-search {
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.deck-panel-search .deck-panel-header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ flex-shrink: 0;
+}
+
+.deck-search-count {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.deck-search-bar-wrap {
+ position: relative;
+ padding: var(--sp-3) var(--sp-3) var(--sp-2);
+ flex-shrink: 0;
+}
+
+.deck-search-bar {
+ width: 100%;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 7px 30px 7px var(--sp-3);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+}
+
+.deck-search-bar::placeholder {
+ color: var(--text-secondary);
+}
+
+.deck-search-bar:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.deck-search-icon {
+ position: absolute;
+ right: 20px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 13px;
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.deck-filter-row {
+ display: flex;
+ gap: var(--sp-2);
+ padding: 0 var(--sp-3) var(--sp-2);
+ flex-shrink: 0;
+}
+
+.deck-filter-select {
+ flex: 1;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 5px var(--sp-2);
+ font-size: 12px;
+ font-family: inherit;
+ outline: none;
+ cursor: pointer;
+ min-width: 0;
+ transition: border-color var(--duration) var(--ease);
+}
+
+.deck-filter-select:hover,
+.deck-filter-select:focus {
+ border-color: var(--accent);
+}
+
+.deck-cmc-filter {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-1);
+ flex-shrink: 0;
+}
+
+.deck-cmc-input {
+ width: 38px;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 5px var(--sp-1);
+ font-size: 12px;
+ font-family: inherit;
+ text-align: center;
+ outline: none;
+ transition: border-color var(--duration) var(--ease);
+ -moz-appearance: textfield;
+}
+
+.deck-cmc-input::-webkit-outer-spin-button,
+.deck-cmc-input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+}
+
+.deck-cmc-input:focus {
+ border-color: var(--accent);
+}
+
+.deck-cmc-sep {
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+/* Color filter row — MTG domain-specific colors */
+.deck-color-filter-row {
+ display: flex;
+ gap: 5px;
+ padding: 0 var(--sp-3) var(--sp-3);
+ flex-shrink: 0;
+}
+
+.deck-color-pip {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 800;
+ line-height: 1;
+ transition: border-color var(--duration) var(--ease), transform var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+ user-select: none;
+ background: var(--surface-1);
+ color: var(--text-secondary);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+ flex-shrink: 0;
+}
+
+.deck-color-pip:hover {
+ border-color: rgba(255, 255, 255, 0.5);
+ transform: scale(1.05);
+}
+
+.deck-color-pip.active {
+ border-color: #fff;
+ transform: scale(1.1);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
+}
+
+.deck-color-W { background: #F9FAF4; color: #333; }
+.deck-color-W.active { background: #F9FAF4; }
+.deck-color-U { background: #0E68AB; color: #fff; }
+.deck-color-U.active { background: #1a85d4; }
+.deck-color-B { background: #2a1f1a; color: #c9a060; }
+.deck-color-B.active { background: #3a2a1a; }
+.deck-color-R { background: #D3202A; color: #fff; }
+.deck-color-R.active { background: #e83040; }
+.deck-color-G { background: #00733E; color: #fff; }
+.deck-color-G.active { background: #009950; }
+.deck-color-C { background: #7a7a7a; color: #fff; }
+.deck-color-C.active { background: #9a9a9a; }
+
+/* Search results list */
+.deck-search-results {
+ flex: 1;
+ overflow-y: auto;
+ padding: 2px 0;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.deck-search-results::-webkit-scrollbar { width: 4px; }
+.deck-search-results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
+
+.deck-search-footer {
+ padding: var(--sp-2) var(--sp-3);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+/* ── 7. Search Result Row ───────────────────────────────── */
+
+.deck-search-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 5px var(--sp-3);
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+ min-height: 32px;
+}
+
+.deck-search-row:hover {
+ background: var(--accent-dim);
+}
+
+.deck-search-row.in-deck {
+ opacity: 0.55;
+}
+
+.deck-search-row.in-deck:hover {
+ opacity: 0.75;
+ background: var(--success-dim);
+}
+
+.deck-search-row-name {
+ flex: 1;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ min-width: 0;
+}
+
+.deck-search-row-name:hover {
+ color: var(--accent);
+}
+
+.deck-search-row-mana {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.deck-search-row-owned {
+ font-size: 10px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+ white-space: nowrap;
+ min-width: 22px;
+ text-align: right;
+}
+
+.deck-search-row-owned.has-owned {
+ color: var(--success);
+ font-weight: 600;
+}
+
+.deck-search-row-add {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ border-radius: 4px;
+ width: 22px;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all var(--duration) var(--ease);
+ padding: 0;
+ line-height: 1;
+}
+
+.deck-search-row-add:hover {
+ background: var(--accent-dim);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.deck-search-row.in-deck .deck-search-row-add {
+ border-color: rgba(74, 222, 128, 0.4);
+ color: var(--success);
+}
+
+.deck-search-row.in-deck .deck-search-row-add:hover {
+ background: var(--success-dim);
+ border-color: var(--success);
+}
+
+/* Type badges */
+.deck-type-badge {
+ display: inline-block;
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ flex-shrink: 0;
+ line-height: 1.4;
+}
+
+.deck-type-Land { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(251, 191, 36, 0.3); }
+.deck-type-Creature { background: var(--success-dim); color: var(--success); border: 1px solid rgba(74, 222, 128, 0.25); }
+.deck-type-Instant { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(91, 158, 240, 0.25); }
+.deck-type-Sorcery { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(167, 139, 250, 0.25); }
+.deck-type-Artifact { background: rgba(139, 148, 158, 0.15); color: var(--text-secondary); border: 1px solid rgba(139, 148, 158, 0.25); }
+.deck-type-Enchantment { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(167, 139, 250, 0.2); }
+.deck-type-Planeswalker { background: var(--warning-dim); color: var(--gold); border: 1px solid rgba(252, 211, 77, 0.25); }
+.deck-type-Other { background: rgba(139, 148, 158, 0.1); color: var(--text-secondary); border: 1px solid rgba(139, 148, 158, 0.2); }
+
+/* ── 8. Mana Pip Display ────────────────────────────────── */
+
+.deck-mana-cost {
+ display: inline-flex;
+ gap: 2px;
+ align-items: center;
+ flex-wrap: nowrap;
+}
+
+/* MTG mana pip colors — domain-specific */
+.deck-mana-pip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 1;
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ flex-shrink: 0;
+}
+
+.deck-pip-W { background: #F9FAF4; color: #333; }
+.deck-pip-U { background: #0E68AB; color: #fff; }
+.deck-pip-B { background: #2a1f1a; color: #c9a060; border-color: #5a3a1a; }
+.deck-pip-R { background: #D3202A; color: #fff; }
+.deck-pip-G { background: #00733E; color: #fff; }
+.deck-pip-C { background: #9a9a9a; color: #fff; }
+.deck-pip-X { background: #4a4a4a; color: #fff; }
+.deck-pip-N { background: var(--surface-1); color: var(--text); border-color: var(--border); }
+
+/* ── 9. Center Column: Card Groups ──────────────────────── */
+
+.deck-panel-cards {
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+}
+
+.deck-card-list-header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.deck-card-list-meta {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.deck-card-list-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.deck-card-groups {
+ flex: 1;
+ padding: var(--sp-2) 0;
+}
+
+/* Section / group header */
+.deck-section {
+ margin-bottom: 2px;
+}
+
+.deck-section-commander .deck-section-header {
+ background: var(--accent-dim);
+ border-left: 3px solid var(--accent);
+}
+
+.deck-section-commander .deck-section-name {
+ color: var(--accent);
+}
+
+.deck-section-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-2) var(--sp-4);
+ cursor: pointer;
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--border);
+ border-top: 1px solid var(--border);
+ position: sticky;
+ top: 40px;
+ z-index: 9;
+ user-select: none;
+ transition: background var(--duration) var(--ease);
+}
+
+.deck-section-header:hover {
+ background: var(--accent-dim);
+}
+
+.deck-section-toggle {
+ font-size: 9px;
+ color: var(--text-secondary);
+ transition: transform 0.2s var(--ease);
+ flex-shrink: 0;
+}
+
+.deck-section-toggle.collapsed {
+ transform: rotate(-90deg);
+}
+
+.deck-section-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text);
+ flex: 1;
+}
+
+.deck-section-count {
+ font-size: 11px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.deck-section-progress {
+ flex: 0 0 60px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--bg);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.deck-section-progress-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.deck-section-progress-fill.status-ok { background: var(--success); }
+.deck-section-progress-fill.status-warn { background: var(--warning); }
+.deck-section-progress-fill.status-low { background: var(--error); }
+.deck-section-progress-fill.status-over { background: var(--purple); }
+.deck-section-progress-fill.status-neutral { background: var(--text-secondary); }
+
+/* Section body - card rows */
+.deck-section-body {
+ overflow: hidden;
+ transition: max-height 0.2s ease;
+}
+
+.deck-section-body.collapsed {
+ max-height: 0 !important;
+}
+
+/* Card row in deck */
+.deck-card-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-1) var(--sp-4);
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+ min-height: 28px;
+}
+
+.deck-card-row:hover {
+ background: var(--accent-dim);
+}
+
+.deck-card-row.is-commander {
+ background: rgba(252, 211, 77, 0.04);
+}
+
+.deck-card-row.is-commander .deck-card-row-name {
+ color: var(--gold);
+}
+
+.deck-card-commander-badge {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.35);
+ border-radius: 3px;
+ padding: 1px 6px;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.deck-card-row-name {
+ flex: 1;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ min-width: 0;
+ transition: color 0.1s;
+}
+
+.deck-card-row-name:hover {
+ color: var(--accent);
+}
+
+.deck-card-row-mana {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.deck-card-role-chip {
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ flex-shrink: 0;
+ background: rgba(139, 148, 158, 0.12);
+ color: var(--text-secondary);
+ border: 1px solid rgba(139, 148, 158, 0.2);
+ cursor: pointer;
+ transition: all var(--duration) var(--ease);
+}
+
+.deck-card-role-chip:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Quantity controls */
+.deck-qty-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-1);
+ flex-shrink: 0;
+}
+
+.deck-qty-btn {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ border-radius: 3px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ transition: all 0.12s;
+ flex-shrink: 0;
+}
+
+.deck-qty-btn:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--accent-dim);
+}
+
+.deck-qty-num {
+ font-size: 12px;
+ color: var(--text);
+ min-width: 14px;
+ text-align: center;
+ font-weight: 600;
+}
+
+.deck-card-remove-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0 2px;
+ border-radius: 3px;
+ line-height: 1;
+ flex-shrink: 0;
+ opacity: 0;
+ transition: opacity 0.12s, color 0.12s;
+}
+
+.deck-card-row:hover .deck-card-remove-btn {
+ opacity: 1;
+}
+
+.deck-card-remove-btn:hover {
+ color: var(--error);
+}
+
+/* Owned indicator on card rows */
+.deck-card-owned-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--success);
+ opacity: 0.8;
+}
+
+.deck-card-owned-dot.unowned {
+ background: var(--text-secondary);
+ opacity: 0.4;
+}
+
+/* Quick add row */
+.deck-quickadd-row {
+ display: flex;
+ gap: var(--sp-2);
+ align-items: center;
+ padding: var(--sp-3) var(--sp-4);
+ border-top: 1px solid var(--border);
+ background: var(--surface-1);
+ flex-shrink: 0;
+}
+
+.deck-quickadd-wrap {
+ flex: 1;
+ position: relative;
+ min-width: 0;
+}
+
+.deck-quickadd-input {
+ width: 100%;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 6px var(--sp-3);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+}
+
+.deck-quickadd-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.deck-quickadd-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.deck-quickadd-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ z-index: 50;
+ max-height: 200px;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.deck-quickadd-item {
+ padding: 7px var(--sp-3);
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+}
+
+.deck-quickadd-item:hover,
+.deck-quickadd-item.active {
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+
+.deck-quickadd-item:last-child {
+ border-bottom: none;
+}
+
+.deck-quickadd-item-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.deck-quickadd-item-type {
+ font-size: 10px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+/* Stats footer */
+.deck-stats-footer {
+ display: flex;
+ gap: var(--sp-2);
+ padding: var(--sp-2) var(--sp-4);
+ border-top: 1px solid var(--border);
+ background: var(--surface-1);
+ flex-shrink: 0;
+}
+
+.deck-stat-pill {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-1);
+ padding: var(--sp-1) var(--sp-3);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
+
+.deck-stat-pill-num {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.deck-stat-owned {
+ color: var(--success);
+}
+
+.deck-stat-pill-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ letter-spacing: 0.4px;
+}
+
+/* ── 10. Right Column: Ratio Dashboard ──────────────────── */
+
+.deck-ratio-bars {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.deck-ratio-placeholder {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: center;
+ padding: var(--sp-3) 0;
+ font-style: italic;
+}
+
+.deck-ratio-bar-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ cursor: pointer;
+ border-radius: var(--radius-md);
+ padding: var(--sp-1) var(--sp-2);
+ transition: background var(--duration) var(--ease);
+}
+
+.deck-ratio-bar-row:hover {
+ background: var(--accent-dim);
+}
+
+.deck-ratio-bar-row.active-filter {
+ background: var(--accent-dim);
+ outline: 1px solid var(--accent-glow);
+}
+
+.deck-ratio-bar-label {
+ font-size: 11px;
+ color: var(--text-secondary);
+ width: 80px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.deck-ratio-bar-track {
+ flex: 1;
+ height: 8px;
+ background: var(--bg);
+ border-radius: 4px;
+ overflow: visible;
+ position: relative;
+}
+
+.deck-ratio-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.35s ease;
+ position: relative;
+}
+
+.deck-ratio-bar-fill.status-ok { background: var(--success); }
+.deck-ratio-bar-fill.status-warn { background: var(--warning); }
+.deck-ratio-bar-fill.status-low { background: var(--error); }
+.deck-ratio-bar-fill.status-over { background: var(--purple); }
+.deck-ratio-bar-fill.status-neutral { background: var(--text-secondary); }
+
+/* Target range indicator lines */
+.deck-ratio-bar-target-min,
+.deck-ratio-bar-target-max {
+ position: absolute;
+ top: -2px;
+ bottom: -2px;
+ width: 2px;
+ border-radius: 1px;
+ background: rgba(255, 255, 255, 0.25);
+ z-index: 1;
+}
+
+.deck-ratio-bar-nums {
+ font-size: 11px;
+ color: var(--text-secondary);
+ width: 50px;
+ flex-shrink: 0;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.deck-ratio-bar-nums .deck-ratio-current {
+ font-weight: 700;
+ color: var(--text);
+}
+
+/* ── 11. Fix My Ratios Section ──────────────────────────── */
+
+.deck-shortfalls {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-1);
+ margin-bottom: var(--sp-3);
+}
+
+.deck-shortfall-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 5px var(--sp-2);
+ border-radius: var(--radius-md);
+ background: var(--surface-1);
+ font-size: 12px;
+}
+
+.deck-shortfall-icon {
+ font-size: 13px;
+ flex-shrink: 0;
+}
+
+.deck-shortfall-text {
+ flex: 1;
+ color: var(--text);
+}
+
+.deck-shortfall-delta {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 1px 6px;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+
+.deck-shortfall-delta.needs-more {
+ color: var(--warning);
+ background: var(--warning-dim);
+}
+
+.deck-shortfall-delta.over-count {
+ color: var(--error);
+ background: var(--error-dim);
+}
+
+.deck-shortfall-delta.on-target {
+ color: var(--success);
+ background: var(--success-dim);
+}
+
+.deck-fix-suggestions {
+ margin-top: var(--sp-2);
+}
+
+.deck-fix-group {
+ margin-bottom: var(--sp-3);
+}
+
+.deck-fix-group-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--text-secondary);
+ margin-bottom: var(--sp-1);
+ padding: 0 2px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.deck-fix-card {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 5px var(--sp-2);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ margin-bottom: 3px;
+ transition: border-color var(--duration) var(--ease);
+}
+
+.deck-fix-card:hover {
+ border-color: var(--accent);
+}
+
+.deck-fix-card-name {
+ flex: 1;
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ min-width: 0;
+}
+
+.deck-fix-card-name:hover {
+ color: var(--accent);
+}
+
+.deck-fix-card-owned {
+ font-size: 10px;
+ color: var(--success);
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+/* ── 12. EDHREC Recommendations ─────────────────────────── */
+
+.deck-edhrec-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: var(--sp-3);
+ border-bottom: 1px solid var(--border);
+ margin-bottom: var(--sp-3);
+}
+
+.deck-edhrec-list {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.deck-edhrec-bulk {
+ margin-top: var(--sp-3);
+ padding-top: var(--sp-3);
+ border-top: 1px solid var(--border);
+}
+
+.deck-edhrec-card {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 5px var(--sp-2);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease);
+}
+
+.deck-edhrec-card:hover {
+ border-color: var(--accent-glow);
+ background: var(--accent-dim);
+}
+
+.deck-edhrec-card.in-deck {
+ opacity: 0.5;
+ border-color: rgba(74, 222, 128, 0.3);
+}
+
+.deck-edhrec-card-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.deck-edhrec-card-name {
+ font-size: 12px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+ line-height: 1.3;
+}
+
+.deck-edhrec-card-name:hover {
+ color: var(--accent);
+}
+
+.deck-edhrec-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: 2px;
+ flex-wrap: wrap;
+}
+
+.deck-edhrec-pct {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.deck-edhrec-pct strong {
+ color: var(--accent);
+}
+
+.deck-edhrec-synergy {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
+.deck-edhrec-synergy.positive {
+ color: var(--success);
+}
+
+.deck-owned-badge {
+ font-size: 9px;
+ font-weight: 700;
+ padding: 1px 5px;
+ border-radius: 3px;
+ background: var(--success-dim);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+ flex-shrink: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+/* ── 13. Toggle Switch ───────────────────────────────────── */
+
+.deck-toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-2);
+ cursor: pointer;
+ user-select: none;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.deck-toggle-check {
+ display: none;
+}
+
+.deck-toggle-track {
+ width: 32px;
+ height: 18px;
+ background: var(--border);
+ border-radius: 9px;
+ position: relative;
+ transition: background 0.2s;
+ flex-shrink: 0;
+}
+
+.deck-toggle-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ background: var(--text-secondary);
+ border-radius: 50%;
+ transition: transform 0.2s, background 0.2s;
+}
+
+.deck-toggle-check:checked + .deck-toggle-track {
+ background: var(--accent-glow);
+}
+
+.deck-toggle-check:checked + .deck-toggle-track .deck-toggle-thumb {
+ transform: translateX(14px);
+ background: var(--accent);
+}
+
+/* ── 14. Card Preview Tooltip ───────────────────────────── */
+
+.deck-card-preview {
+ position: fixed;
+ z-index: 60;
+ pointer-events: none;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8);
+ width: 180px;
+ transition: opacity 0.1s;
+}
+
+.deck-card-preview-img {
+ width: 100%;
+ display: block;
+ border-radius: var(--radius-md);
+}
+
+/* ── 15. Toast Notifications ────────────────────────────── */
+
+.deck-toast-container {
+ position: fixed;
+ bottom: var(--sp-5);
+ right: var(--sp-5);
+ z-index: 500;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ pointer-events: none;
+}
+
+.deck-toast {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3) var(--sp-4);
+ font-size: 13px;
+ color: var(--text);
+ min-width: 240px;
+ max-width: 360px;
+ box-shadow: var(--shadow-lg);
+ animation: deck-toast-in 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ pointer-events: auto;
+ border-left: 3px solid var(--border);
+}
+
+.deck-toast.toast-success { border-left-color: var(--success); }
+.deck-toast.toast-error { border-left-color: var(--error); }
+.deck-toast.toast-warning { border-left-color: var(--warning); }
+.deck-toast.toast-info { border-left-color: var(--accent); }
+
+.deck-toast-icon {
+ font-size: 15px;
+ flex-shrink: 0;
+}
+
+.deck-toast-text {
+ flex: 1;
+ line-height: 1.4;
+}
+
+.deck-toast.removing {
+ animation: deck-toast-out 0.2s ease forwards;
+}
+
+@keyframes deck-toast-in {
+ from { opacity: 0; transform: translateX(20px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes deck-toast-out {
+ from { opacity: 1; transform: translateX(0); }
+ to { opacity: 0; transform: translateX(20px); }
+}
+
+/* ── 16. Modal / Dialog ──────────────────────────────────── */
+
+.deck-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.72);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: deck-modal-bg-in 0.15s ease;
+}
+
+@keyframes deck-modal-bg-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.deck-modal {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ width: 90%;
+ max-width: 460px;
+ max-height: 85vh;
+ overflow-y: auto;
+ animation: deck-modal-in 0.2s ease;
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
+}
+
+.deck-modal-wide {
+ max-width: 600px;
+}
+
+@keyframes deck-modal-in {
+ from { opacity: 0; transform: translateY(-12px) scale(0.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.deck-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-4) var(--sp-5) var(--sp-3);
+ border-bottom: 1px solid var(--border);
+}
+
+.deck-modal-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+}
+
+.deck-modal-close-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ transition: color var(--duration) var(--ease), background var(--duration) var(--ease);
+ line-height: 1;
+}
+
+.deck-modal-close-btn:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.deck-modal-body {
+ padding: var(--sp-4) var(--sp-5);
+}
+
+.deck-modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--sp-2);
+ padding: var(--sp-3) var(--sp-5) var(--sp-4);
+ border-top: 1px solid var(--border);
+}
+
+.deck-confirm-message {
+ font-size: 14px;
+ color: var(--text);
+ margin-bottom: var(--sp-3);
+ line-height: 1.5;
+}
+
+.deck-confirm-changes {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3) var(--sp-4);
+ font-size: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-1);
+}
+
+.deck-confirm-change-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ padding: 2px 0;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.deck-confirm-change-row:last-child {
+ border-bottom: none;
+ font-weight: 600;
+ margin-top: var(--sp-1);
+}
+
+.deck-confirm-change-label {
+ color: var(--text-secondary);
+}
+
+.deck-confirm-change-val {
+ color: var(--text);
+}
+
+.deck-confirm-change-val.positive { color: var(--success); }
+.deck-confirm-change-val.negative { color: var(--error); }
+
+/* ── 17. Form fields (New Deck Modal) ───────────────────── */
+
+.deck-form-field {
+ margin-bottom: var(--sp-4);
+}
+
+.deck-form-label {
+ display: block;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ margin-bottom: var(--sp-2);
+}
+
+.deck-form-input {
+ width: 100%;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 9px var(--sp-4);
+ font-size: 14px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+}
+
+.deck-form-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.deck-form-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.deck-commander-search-wrap {
+ position: relative;
+}
+
+.deck-commander-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ z-index: 50;
+ max-height: 200px;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.deck-commander-option {
+ padding: var(--sp-2) var(--sp-4);
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+}
+
+.deck-commander-option:hover,
+.deck-commander-option.active {
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+
+.deck-commander-option:last-child {
+ border-bottom: none;
+}
+
+.deck-commander-option-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.deck-commander-option-color {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.deck-new-commander-selected {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-2) var(--sp-3);
+ margin-top: var(--sp-2);
+ background: var(--accent-dim);
+ border: 1px solid var(--accent-glow);
+ border-radius: var(--radius-md);
+}
+
+.deck-selected-commander-name {
+ flex: 1;
+ font-size: 13px;
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.deck-clear-commander-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0;
+ line-height: 1;
+ transition: color var(--duration) var(--ease);
+}
+
+.deck-clear-commander-btn:hover {
+ color: var(--error);
+}
+
+/* ── 18. Empty States ────────────────────────────────────── */
+
+.deck-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ padding: var(--sp-8) var(--sp-4);
+ text-align: center;
+}
+
+.deck-empty-state-lg {
+ padding: var(--sp-16) var(--sp-6);
+}
+
+.deck-empty-icon {
+ font-size: 32px;
+ opacity: 0.3;
+ line-height: 1;
+}
+
+.deck-empty-state-lg .deck-empty-icon {
+ font-size: 48px;
+}
+
+.deck-empty-text {
+ font-size: 13px;
+ color: var(--text-secondary);
+ max-width: 220px;
+ line-height: 1.5;
+}
+
+/* ── 19. Loading / Skeleton ──────────────────────────────── */
+
+.deck-skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--surface-1) 25%,
+ var(--surface-3) 50%,
+ var(--surface-1) 75%
+ );
+ background-size: 200% 100%;
+ animation: deck-shimmer 1.4s infinite;
+ border-radius: 4px;
+}
+
+@keyframes deck-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.deck-skeleton-row {
+ height: 28px;
+ margin: var(--sp-1) var(--sp-4);
+ border-radius: 4px;
+}
+
+.deck-loading-spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: deck-spin 0.6s linear infinite;
+ flex-shrink: 0;
+}
+
+@keyframes deck-spin {
+ to { transform: rotate(360deg); }
+}
+
+.deck-loading-inline {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ padding: var(--sp-5);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* ── 20. Scrollbar thin style for inner lists ───────────── */
+
+.deck-edhrec-list,
+.deck-fix-suggestions {
+ max-height: 260px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.deck-edhrec-list::-webkit-scrollbar,
+.deck-fix-suggestions::-webkit-scrollbar {
+ width: 4px;
+}
+
+.deck-edhrec-list::-webkit-scrollbar-thumb,
+.deck-fix-suggestions::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 2px;
+}
+
+/* ── 21. Mana curve sparkline ───────────────────────────── */
+
+.deck-mana-curve {
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ height: 32px;
+ padding: 0 2px;
+}
+
+.deck-curve-bar {
+ flex: 1;
+ background: var(--accent-glow);
+ border-radius: 2px 2px 0 0;
+ min-height: 2px;
+ transition: height 0.3s ease;
+ position: relative;
+}
+
+.deck-curve-bar:hover {
+ background: var(--accent);
+}
+
+.deck-curve-bar-label {
+ position: absolute;
+ bottom: -14px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 8px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+/* ── 22. Import Status ──────────────────────────────────── */
+
+.deck-import-status {
+ margin-top: var(--sp-3);
+ padding: var(--sp-3);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+
+/* ── 23. Responsive ──────────────────────────────────────── */
+
+@media (max-width: 1200px) {
+ .deck-col-right {
+ width: 300px;
+ }
+}
+
+@media (max-width: 1000px) {
+ .deck-main {
+ flex-direction: column;
+ }
+
+ .deck-col-left,
+ .deck-col-center,
+ .deck-col-right {
+ width: 100%;
+ position: static;
+ height: auto;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .deck-col-left {
+ max-height: 60vh;
+ overflow-y: auto;
+ }
+
+ .deck-col-center {
+ min-height: unset;
+ }
+
+ .deck-col-right {
+ height: auto;
+ }
+
+ .deck-topbar {
+ flex-wrap: wrap;
+ gap: var(--sp-2);
+ padding: var(--sp-3);
+ }
+
+ .deck-topbar-center {
+ order: 2;
+ flex-wrap: wrap;
+ gap: var(--sp-2);
+ }
+
+ .deck-topbar-actions {
+ order: 3;
+ width: 100%;
+ }
+
+ .deck-name-input {
+ width: 150px;
+ }
+}
+
+@media (max-width: 768px) {
+ .deck-topbar-commander {
+ flex-wrap: wrap;
+ }
+
+ .deck-nav-links {
+ display: none;
+ }
+
+ .deck-color-chips {
+ flex-wrap: wrap;
+ }
+
+ .deck-modal {
+ width: 95%;
+ max-width: none;
+ margin: var(--sp-3);
+ }
+
+ .deck-card-preview {
+ display: none !important;
+ }
+
+ .deck-col-left {
+ max-height: 45vh;
+ }
+}
+
+/* ── 24. Utility ─────────────────────────────────────────── */
+
+.deck-visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0,0,0,0);
+ white-space: nowrap;
+}
+
+.deck-divider {
+ height: 1px;
+ background: var(--border);
+ margin: var(--sp-2) 0;
+}
+
+.deck-text-muted {
+ color: var(--text-secondary);
+}
+
+.deck-text-accent {
+ color: var(--accent);
+}
+
+.deck-text-success {
+ color: var(--success);
+}
+
+.deck-text-danger {
+ color: var(--error);
+}
+
+.deck-text-warning {
+ color: var(--warning);
+}
+
+/* ── 20. AI Panel Styles (Perplexity Research/Generate) ──── */
+
+.deck-panel-ai .deck-panel-header {
+ background: linear-gradient(135deg, var(--surface-1) 70%, rgba(91, 158, 240, 0.1));
+}
+
+.deck-panel-ai .deck-panel-title {
+ color: var(--accent);
+}
+
+.deck-ai-controls {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.deck-ai-row {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--sp-3);
+}
+
+.deck-form-field-sm {
+ margin-bottom: 0;
+}
+
+.deck-form-field-half {
+ flex: 1;
+ min-width: 0;
+}
+
+.deck-form-label-sm {
+ display: block;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.deck-form-input-sm {
+ width: 100%;
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 10px;
+ font-size: 12px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+}
+
+.deck-form-input-sm::placeholder {
+ color: var(--text-secondary);
+}
+
+.deck-form-input-sm:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.deck-toggle-label-sm {
+ font-size: 11px;
+ white-space: nowrap;
+ padding-bottom: 4px;
+}
+
+.deck-btn-ai {
+ background: linear-gradient(135deg, rgba(91, 158, 240, 0.15), rgba(139, 92, 246, 0.15));
+ color: var(--accent);
+ border-color: rgba(91, 158, 240, 0.35);
+ font-weight: 600;
+ margin-top: var(--sp-2);
+ position: relative;
+ overflow: hidden;
+}
+
+.deck-btn-ai::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(135deg, rgba(91, 158, 240, 0.08), rgba(139, 92, 246, 0.12));
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.deck-btn-ai:hover:not(:disabled)::before {
+ opacity: 1;
+}
+
+.deck-btn-ai:hover:not(:disabled) {
+ border-color: var(--accent);
+ box-shadow: 0 0 16px rgba(91, 158, 240, 0.2);
+}
+
+.deck-btn-ai:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* AI Loading Spinner */
+.deck-ai-loading {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-4) 0;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.deck-ai-spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: ai-spin 0.8s linear infinite;
+ flex-shrink: 0;
+}
+
+@keyframes ai-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* AI Results */
+.deck-ai-results {
+ margin-top: var(--sp-3);
+}
+
+.deck-ai-section {
+ margin-bottom: var(--sp-4);
+}
+
+.deck-ai-section-title {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--accent);
+ margin-bottom: var(--sp-2);
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--border);
+}
+
+.deck-ai-rating {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ margin-bottom: var(--sp-3);
+ padding: var(--sp-3);
+ background: var(--surface-1);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+}
+
+.deck-ai-rating-num {
+ font-size: 28px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.deck-ai-rating-num.low { color: var(--error); }
+.deck-ai-rating-num.mid { color: var(--warning); }
+.deck-ai-rating-num.high { color: var(--success); }
+
+.deck-ai-rating-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ flex: 1;
+ min-width: 0;
+}
+
+.deck-ai-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.deck-ai-list li {
+ font-size: 12px;
+ color: var(--text);
+ padding: 4px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ line-height: 1.4;
+}
+
+.deck-ai-list li:last-child {
+ border-bottom: none;
+}
+
+.deck-ai-list li::before {
+ content: '\2022';
+ color: var(--text-secondary);
+ margin-right: 6px;
+}
+
+/* Cuts / Adds card rows */
+.deck-ai-card-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 5px var(--sp-2);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ margin-bottom: 3px;
+ transition: border-color var(--duration) var(--ease);
+}
+
+.deck-ai-card-row:hover {
+ border-color: var(--accent-glow);
+}
+
+.deck-ai-card-row.cut-row {
+ border-left: 3px solid var(--error);
+}
+
+.deck-ai-card-row.add-row {
+ border-left: 3px solid var(--success);
+}
+
+.deck-ai-card-name {
+ font-size: 12px;
+ color: var(--text);
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+}
+
+.deck-ai-card-name:hover {
+ color: var(--accent);
+}
+
+.deck-ai-card-role {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ padding: 1px 5px;
+ border-radius: 3px;
+ background: var(--accent-dim);
+ color: var(--accent);
+ flex-shrink: 0;
+}
+
+.deck-ai-card-price {
+ font-size: 10px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.deck-ai-card-reason {
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+ line-height: 1.3;
+}
+
+.deck-ai-card-owned-badge {
+ font-size: 8px;
+ font-weight: 700;
+ padding: 1px 4px;
+ border-radius: 3px;
+ background: var(--success-dim);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+ flex-shrink: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.deck-ai-card-add-btn {
+ background: transparent;
+ border: 1px solid var(--success);
+ color: var(--success);
+ border-radius: var(--radius-sm);
+ padding: 2px 6px;
+ font-size: 10px;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background var(--duration) var(--ease);
+}
+
+.deck-ai-card-add-btn:hover {
+ background: var(--success-dim);
+}
+
+.deck-ai-notes {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+ padding: var(--sp-2) 0;
+}
+
+.deck-ai-total {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ margin-top: var(--sp-2);
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.deck-ai-total strong {
+ color: var(--text);
+}
+
+/* AI Generate — Commander search dropdown */
+.deck-ai-commander-wrap {
+ position: relative;
+}
+
+.deck-ai-commander-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ z-index: 50;
+ max-height: 180px;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.deck-ai-commander-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ cursor: pointer;
+ transition: background 0.12s;
+ font-size: 12px;
+}
+
+.deck-ai-commander-option:hover,
+.deck-ai-commander-option.active {
+ background: var(--accent-dim);
+}
+
+.deck-ai-commander-option-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.deck-ai-commander-selected {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ margin-top: 4px;
+ padding: 4px 8px;
+ background: var(--accent-dim);
+ border: 1px solid var(--accent-glow);
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ color: var(--accent);
+}
+
+.deck-ai-commander-selected .deck-clear-commander-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0 2px;
+}
+
+.deck-ai-commander-selected .deck-clear-commander-btn:hover {
+ color: var(--error);
+}
+
+/* AI Generate — Apply to Deck button */
+.deck-btn-apply-ai {
+ background: var(--success-dim);
+ color: var(--success);
+ border-color: rgba(74, 222, 128, 0.4);
+ font-weight: 600;
+ margin-top: var(--sp-3);
+}
+
+.deck-btn-apply-ai:hover:not(:disabled) {
+ background: rgba(74, 222, 128, 0.25);
+ border-color: var(--success);
+ box-shadow: 0 0 12px var(--success-dim);
+}
+
+/* AI error message */
+.deck-ai-error {
+ font-size: 12px;
+ color: var(--error);
+ padding: var(--sp-3) 0;
+ line-height: 1.4;
+}
+
+/* Strategy text block */
+.deck-ai-strategy {
+ font-size: 12px;
+ color: var(--text);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3);
+ line-height: 1.5;
+ margin-bottom: var(--sp-3);
+}
+
+/* Stats row for generated deck */
+.deck-ai-stats {
+ display: flex;
+ gap: var(--sp-3);
+ flex-wrap: wrap;
+ margin-bottom: var(--sp-3);
+}
+
+.deck-ai-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ flex: 1;
+ min-width: 60px;
+}
+
+.deck-ai-stat-value {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.deck-ai-stat-label {
+ font-size: 9px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+/* ═══════════════════════════════════════════════════════════
+ AI Deep Analysis — Research Results Styles
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── Header Row: Rating + Bracket + Archetype inline ──── */
+.deck-ai-header-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ flex-wrap: wrap;
+ margin-bottom: var(--sp-3);
+ padding-bottom: var(--sp-3);
+ border-bottom: 1px solid var(--border);
+}
+
+.deck-ai-rating-sublabel {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--text-secondary);
+ margin-left: 1px;
+}
+
+/* ── Bracket Badge ──────────────────────────────────────── */
+.deck-ai-bracket-badge {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 3px 10px;
+ border-radius: 20px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+}
+
+.deck-ai-bracket-badge.bracket-1 {
+ background: rgba(74, 222, 128, 0.15);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+}
+
+.deck-ai-bracket-badge.bracket-2 {
+ background: rgba(91, 158, 240, 0.15);
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.3);
+}
+
+.deck-ai-bracket-badge.bracket-3 {
+ background: rgba(251, 191, 36, 0.15);
+ color: var(--warning);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.deck-ai-bracket-badge.bracket-4 {
+ background: rgba(248, 113, 113, 0.15);
+ color: var(--error);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+
+/* ── Archetype Badge ────────────────────────────────────── */
+.deck-ai-archetype-badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: 20px;
+ background: var(--purple-dim);
+ color: var(--purple);
+ border: 1px solid rgba(167, 139, 250, 0.3);
+ text-transform: capitalize;
+ flex-shrink: 0;
+}
+
+/* ── Rating Explanation ─────────────────────────────────── */
+.deck-ai-rating-explanation {
+ font-style: italic;
+ color: var(--text-secondary);
+ font-size: 11px;
+ padding: var(--sp-1) 0 var(--sp-2);
+ border-bottom: 1px solid var(--border-subtle);
+ margin-bottom: var(--sp-2);
+}
+
+/* ── Bracket Detail block ──────────────────────────────── */
+.deck-ai-bracket-detail {
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+/* ── Win Conditions ─────────────────────────────────────── */
+.deck-ai-wincon {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3);
+ margin-bottom: var(--sp-2);
+}
+
+.deck-ai-wincon-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-2);
+}
+
+.deck-ai-wincon-name {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.deck-ai-wincon-cards {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: var(--sp-1);
+}
+
+/* ── Reliability Badge (high/mid/low) ──────────────────── */
+.deck-ai-reliability-badge {
+ font-size: 9px;
+ font-weight: 700;
+ padding: 2px 7px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ flex-shrink: 0;
+}
+
+.deck-ai-reliability-badge.high {
+ background: rgba(74, 222, 128, 0.15);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+}
+
+.deck-ai-reliability-badge.mid {
+ background: rgba(251, 191, 36, 0.15);
+ color: var(--warning);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.deck-ai-reliability-badge.low {
+ background: rgba(248, 113, 113, 0.15);
+ color: var(--error);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+
+/* ── Card Chip (inline card references) ─────────────────── */
+.deck-ai-card-chip {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 4px;
+ background: var(--accent-dim);
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.2);
+ white-space: nowrap;
+}
+
+.deck-ai-card-chip-warn {
+ background: var(--error-dim);
+ color: var(--error);
+ border-color: rgba(248, 113, 113, 0.25);
+}
+
+/* ── Synergy Packages ───────────────────────────────────── */
+.deck-ai-synergy-pkg {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3);
+ margin-bottom: var(--sp-2);
+}
+
+.deck-ai-synergy-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ margin-bottom: var(--sp-2);
+}
+
+.deck-ai-synergy-name {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+/* ── Threat Assessment (game phases) ───────────────────── */
+.deck-ai-phases {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.deck-ai-phase {
+ display: flex;
+ gap: var(--sp-3);
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ align-items: flex-start;
+}
+
+.deck-ai-phase-label {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--accent);
+ flex-shrink: 0;
+ min-width: 80px;
+ padding-top: 1px;
+}
+
+.deck-ai-phase-text {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+.deck-ai-phase-vuln .deck-ai-phase-label {
+ color: var(--error);
+}
+
+.deck-ai-phase-vuln {
+ border-color: rgba(248, 113, 113, 0.2);
+ background: var(--error-dim);
+}
+
+/* ── Strengths / Weaknesses side-by-side grid ──────────── */
+.deck-ai-sw-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--sp-3);
+}
+
+.deck-ai-sw-col {
+ min-width: 0;
+}
+
+.deck-ai-sw-title-good {
+ color: var(--success) !important;
+}
+
+.deck-ai-sw-title-bad {
+ color: var(--error) !important;
+}
+
+.deck-ai-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.deck-ai-list li {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 3px 0;
+ line-height: 1.4;
+ position: relative;
+ padding-left: 14px;
+}
+
+.deck-ai-list li::before {
+ content: '\2022';
+ position: absolute;
+ left: 2px;
+ color: var(--text-tertiary);
+}
+
+/* ── Role Analysis (progress bar rows) ─────────────────── */
+.deck-ai-role-gaps {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.deck-ai-role-row {
+ display: grid;
+ grid-template-columns: 90px 1fr 50px;
+ align-items: center;
+ gap: var(--sp-2);
+ font-size: 12px;
+}
+
+.deck-ai-role-name {
+ font-weight: 600;
+ color: var(--text);
+ text-transform: capitalize;
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.deck-ai-role-bar-wrap {
+ height: 8px;
+ border-radius: 4px;
+ background: var(--surface-2);
+ overflow: hidden;
+}
+
+.deck-ai-role-bar {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.4s ease;
+ min-width: 2px;
+}
+
+.deck-ai-role-bar.good {
+ background: var(--success);
+}
+
+.deck-ai-role-bar.warn {
+ background: var(--warning);
+}
+
+.deck-ai-role-bar.bad {
+ background: var(--error);
+}
+
+.deck-ai-role-nums {
+ font-size: 10px;
+ color: var(--text-secondary);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.deck-ai-role-note {
+ grid-column: 1 / -1;
+ font-size: 10px;
+ color: var(--text-tertiary);
+ line-height: 1.3;
+ padding-left: 92px;
+ margin-top: -2px;
+}
+
+/* ── Severity Badge (cuts) ──────────────────────────────── */
+.deck-ai-severity-badge {
+ font-size: 8px;
+ font-weight: 700;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ flex-shrink: 0;
+}
+
+.deck-ai-severity-badge.sev-must {
+ background: rgba(248, 113, 113, 0.2);
+ color: var(--error);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+
+.deck-ai-severity-badge.sev-should {
+ background: rgba(251, 191, 36, 0.2);
+ color: var(--warning);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.deck-ai-severity-badge.sev-consider {
+ background: rgba(91, 158, 240, 0.15);
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.2);
+}
+
+/* ── Priority Badge (adds) ──────────────────────────────── */
+.deck-ai-priority-badge {
+ font-size: 8px;
+ font-weight: 700;
+ padding: 2px 6px;
+ border-radius: 3px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ flex-shrink: 0;
+}
+
+.deck-ai-priority-badge.pri-critical {
+ background: rgba(248, 113, 113, 0.2);
+ color: var(--error);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+
+.deck-ai-priority-badge.pri-high {
+ background: rgba(251, 191, 36, 0.2);
+ color: var(--warning);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.deck-ai-priority-badge.pri-medium {
+ background: rgba(91, 158, 240, 0.15);
+ color: var(--accent);
+ border: 1px solid rgba(91, 158, 240, 0.2);
+}
+
+.deck-ai-priority-badge.pri-nice {
+ background: rgba(74, 222, 128, 0.12);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.2);
+}
+
+/* ── Card Detail line (reason / synergy) ────────────────── */
+.deck-ai-card-detail {
+ font-size: 10px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ padding: 2px 0 0 14px;
+ margin-top: 2px;
+}
+
+/* ── Synergy Tag (inline in card detail) ────────────────── */
+.deck-ai-synergy-tag {
+ display: inline-block;
+ font-size: 9px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--purple-dim);
+ color: var(--purple);
+ border: 1px solid rgba(167, 139, 250, 0.2);
+ margin-left: 4px;
+ vertical-align: middle;
+}
+
+/* ── Deep Analysis responsive tweak ─────────────────────── */
+@media (max-width: 600px) {
+ .deck-ai-sw-grid {
+ grid-template-columns: 1fr;
+ }
+ .deck-ai-role-row {
+ grid-template-columns: 70px 1fr 40px;
+ }
+ .deck-ai-role-note {
+ padding-left: 72px;
+ }
+ .deck-ai-phase {
+ flex-direction: column;
+ gap: var(--sp-1);
+ }
+ .deck-ai-phase-label {
+ min-width: auto;
+ }
+}
diff --git a/lan-client/deckbuilder.html b/lan-client/deckbuilder.html
new file mode 100644
index 0000000..f9059fa
--- /dev/null
+++ b/lan-client/deckbuilder.html
@@ -0,0 +1,495 @@
+
+
+
+
+
+ Commander AI Lab — Deck Builder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚔
+
Create or select a deck to start building
+
+ New Deck
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Load a deck to see ratios
+
+
+
+
+
+
+
+
+
+
No analysis available
+
+
+
+ ⚡ Auto-suggest from Collection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Owned Only
+
+
+ ↺ Refresh
+
+
+
+
Load a deck to see recommendations
+
+
+
+ Add All Owned (respect ratios)
+
+
+
+
+
+
+
+
+
+
+
+ Analysis Goal
+
+
+
+
+ Omit Cards (comma-separated)
+
+
+
+ 🔍 Analyze Deck
+
+
+
+
+
Analyzing with Claude Opus...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Omit Cards (comma-separated)
+
+
+
+ ✨ Generate Full Deck
+
+
+
+
+
Generating deck with Claude Opus...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paste Text
+ Upload File
+
+
+
+
+
+
+
+
+
Drop a .dck or .txt file here, or click to browse
+
+
+
+
+
+
+
+ Create new deck
+
+
+ Add to current deck
+
+
+ Replace current deck cards
+
+
+
+
+ Deck name (optional):
+
+
+
+
+
Importing cards (Scryfall lookup)...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lan-client/deckbuilder.js b/lan-client/deckbuilder.js
new file mode 100644
index 0000000..ee1c157
--- /dev/null
+++ b/lan-client/deckbuilder.js
@@ -0,0 +1,2751 @@
+/* ═══════════════════════════════════════════════════════════
+ Commander AI Lab — Deck Builder
+ Pure vanilla JS, no frameworks.
+ Communicates with backend via fetch() to relative /api/ URLs.
+ ═══════════════════════════════════════════════════════════ */
+
+'use strict';
+
+/* ── LAN Client API Base URL ────────────────────────────────
+ When served from a different host than lab_api.py, set
+ window.LAB_API_BASE before this script loads. */
+const LAB_API_BASE = window.LAB_API_BASE || '';
+
+/* ── Mana Cost Rendering ─────────────────────────────────── */
+
+const MANA_COLORS = {
+ W: 'deck-pip-W',
+ U: 'deck-pip-U',
+ B: 'deck-pip-B',
+ R: 'deck-pip-R',
+ G: 'deck-pip-G',
+ C: 'deck-pip-C',
+ X: 'deck-pip-X',
+};
+
+/**
+ * Parse a mana cost string like "{2}{W}{U}" and return an array
+ * of { label, cls } objects.
+ */
+function parseMana(costStr) {
+ if (!costStr) return [];
+ const pips = [];
+ const regex = /\{([^}]+)\}/g;
+ let m;
+ while ((m = regex.exec(costStr)) !== null) {
+ const sym = m[1].toUpperCase();
+ if (MANA_COLORS[sym]) {
+ pips.push({ label: sym, cls: MANA_COLORS[sym] });
+ } else if (/^\d+$/.test(sym)) {
+ pips.push({ label: sym, cls: 'deck-pip-N' });
+ } else if (sym === 'X') {
+ pips.push({ label: 'X', cls: 'deck-pip-X' });
+ } else {
+ // Hybrid or phyrexian — pick first color
+ const first = sym[0];
+ pips.push({ label: first, cls: MANA_COLORS[first] || 'deck-pip-N' });
+ }
+ }
+ return pips;
+}
+
+/** Build a mana cost element. */
+function buildManaCost(costStr) {
+ const wrap = el('span', 'deck-mana-cost');
+ for (const pip of parseMana(costStr)) {
+ const span = el('span', `deck-mana-pip ${pip.cls}`);
+ span.textContent = pip.label;
+ wrap.appendChild(span);
+ }
+ return wrap;
+}
+
+/* ── DOM Helpers ─────────────────────────────────────────── */
+
+/** Quick element factory. */
+function el(tag, className, text) {
+ const e = document.createElement(tag);
+ if (className) e.className = className;
+ if (text !== undefined) e.textContent = text;
+ return e;
+}
+
+function qs(sel, root) { return (root || document).querySelector(sel); }
+function qsa(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
+
+/* ── Toast System ────────────────────────────────────────── */
+
+const ToastManager = {
+ container: null,
+ init() {
+ this.container = qs('#deck-toast-container');
+ },
+ show(message, type = 'info', duration = 3000) {
+ const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
+ const toast = el('div', `deck-toast toast-${type}`);
+ const icon = el('span', 'deck-toast-icon', icons[type] || icons.info);
+ const text = el('span', 'deck-toast-text', message);
+ toast.appendChild(icon);
+ toast.appendChild(text);
+ this.container.appendChild(toast);
+
+ const remove = () => {
+ toast.classList.add('removing');
+ setTimeout(() => toast.remove(), 220);
+ };
+ setTimeout(remove, duration);
+ toast.addEventListener('click', remove);
+ }
+};
+
+/* ── Card Preview (hover tooltip) ────────────────────────── */
+
+const CardPreview = {
+ el: null,
+ img: null,
+ visible: false, _hoverTimer: null, HOVER_DELAY: 225,
+
+ init() {
+ this.el = qs('#deck-card-preview');
+ this.img = qs('#deck-card-preview-img');
+ },
+
+ show(scryfallId, mx, my) {
+ if (!scryfallId || !this.el) return;
+ this.img.src = `https://api.scryfall.com/cards/${scryfallId}?format=image&version=normal`;
+ this.img.onerror = () => this.hide();
+
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const w = 180;
+ let x = mx + 16;
+ let y = my - 20;
+ if (x + w > vw - 10) x = mx - w - 16;
+ if (y + 252 > vh - 10) y = vh - 262;
+ if (y < 10) y = 10;
+
+ this.el.style.left = `${x}px`;
+ this.el.style.top = `${y}px`;
+ this.el.style.display = 'block';
+ this.visible = true;
+ },
+
+ hide() {
+ clearTimeout(this._hoverTimer); if (this.el) this.el.style.display = 'none';
+ this.visible = false;
+ },
+
+ attach(nameEl, scryfallId) {
+ if (!scryfallId) return;
+ let lastE = null; nameEl.addEventListener('mouseenter', (e) => { lastE = e; clearTimeout(this._hoverTimer); this._hoverTimer = setTimeout(() => { if (lastE) this.show(scryfallId, lastE.clientX, lastE.clientY); }, this.HOVER_DELAY); });
+ nameEl.addEventListener('mousemove', (e) => { lastE = e; if (this.visible) this.show(scryfallId, e.clientX, e.clientY); });
+ nameEl.addEventListener('mouseleave', () => this.hide());
+ }
+};
+
+/* ── Confirmation Modal ───────────────────────────────────── */
+
+const ConfirmDialog = {
+ overlay: null,
+ titleEl: null,
+ messageEl: null,
+ changesEl: null,
+ okBtn: null,
+ cancelBtn: null,
+ closeBtn: null,
+ _resolve: null,
+
+ init() {
+ this.overlay = qs('#deck-confirm-modal');
+ this.titleEl = qs('#deck-confirm-title');
+ this.messageEl = qs('#deck-confirm-message');
+ this.changesEl = qs('#deck-confirm-changes');
+ this.okBtn = qs('#deck-confirm-ok');
+ this.cancelBtn = qs('#deck-confirm-cancel');
+ this.closeBtn = qs('#deck-confirm-close');
+
+ const close = () => { this.overlay.style.display = 'none'; if (this._resolve) this._resolve(false); };
+ this.cancelBtn.addEventListener('click', close);
+ this.closeBtn.addEventListener('click', close);
+ this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) close(); });
+ this.okBtn.addEventListener('click', () => {
+ this.overlay.style.display = 'none';
+ if (this._resolve) this._resolve(true);
+ });
+ },
+
+ /**
+ * @param {object} opts - { title, message, changes: [{label, before, after}] }
+ * @returns {Promise}
+ */
+ show({ title = 'Confirm Action', message = '', changes = [], okLabel = 'OK', cancelLabel = 'Cancel' }) {
+ this.titleEl.textContent = title;
+ this.messageEl.textContent = message;
+ this.changesEl.innerHTML = '';
+ this.okBtn.textContent = okLabel;
+ this.cancelBtn.textContent = cancelLabel;
+
+ if (changes.length > 0) {
+ for (const c of changes) {
+ const row = el('div', 'deck-confirm-change-row');
+ const lbl = el('span', 'deck-confirm-change-label', c.label);
+ const diff = (c.after - c.before);
+ const cls = diff > 0 ? 'positive' : diff < 0 ? 'negative' : '';
+ const val = el('span', `deck-confirm-change-val ${cls}`, `${c.before} → ${c.after}`);
+ row.appendChild(lbl);
+ row.appendChild(val);
+ this.changesEl.appendChild(row);
+ }
+ } else {
+ this.changesEl.style.display = 'none';
+ }
+ if (changes.length > 0) {
+ this.changesEl.style.display = '';
+ }
+
+ this.overlay.style.display = 'flex';
+ return new Promise(resolve => { this._resolve = resolve; });
+ }
+};
+
+/* ── API Helpers ─────────────────────────────────────────── */
+
+async function apiFetch(path, options = {}) {
+ const url = path.startsWith('/api') ? LAB_API_BASE + path : path;
+ const res = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
+ ...options,
+ });
+ if (!res.ok) {
+ let errMsg = `HTTP ${res.status}`;
+ try { const body = await res.json(); errMsg = body.detail || body.message || errMsg; } catch (_) {}
+ throw new Error(errMsg);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+}
+
+function apiGet(path) { return apiFetch(path); }
+function apiPost(path, data) { return apiFetch(path, { method: 'POST', body: JSON.stringify(data) }); }
+function apiPut(path, data) { return apiFetch(path, { method: 'PUT', body: JSON.stringify(data) }); }
+function apiPatch(path, data) { return apiFetch(path, { method: 'PATCH', body: JSON.stringify(data) }); }
+function apiDelete(path) { return apiFetch(path, { method: 'DELETE' }); }
+
+/* ── Scryfall Autocomplete ────────────────────────────────── */
+
+let _scryfallCache = {};
+
+async function scryfallAutocomplete(query) {
+ if (!query || query.length < 2) return [];
+ if (_scryfallCache[query]) return _scryfallCache[query];
+ try {
+ const res = await fetch(`https://api.scryfall.com/cards/autocomplete?q=${encodeURIComponent(query)}`);
+ if (!res.ok) return [];
+ const json = await res.json();
+ _scryfallCache[query] = json.data || [];
+ return _scryfallCache[query];
+ } catch (_) {
+ return [];
+ }
+}
+
+async function scryfallFuzzySearch(query) {
+ try {
+ const res = await fetch(`https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(query)}`);
+ if (!res.ok) return null;
+ return res.json();
+ } catch (_) {
+ return null;
+ }
+}
+
+async function scryfallSearchCards(query) {
+ try {
+ const res = await fetch(`https://api.scryfall.com/cards/search?q=${encodeURIComponent(query)}&unique=cards&order=name`);
+ if (!res.ok) return [];
+ const json = await res.json();
+ return json.data || [];
+ } catch (_) {
+ return [];
+ }
+}
+
+/* ── EDH Target Ratios ────────────────────────────────────── */
+
+const EDH_TARGETS = {
+ Land: { min: 35, target: 37, max: 40, label: 'Lands' },
+ Creature: { min: 20, target: 25, max: 32, label: 'Creatures' },
+ Instant: { min: 7, target: 10, max: 14, label: 'Instants' },
+ Sorcery: { min: 5, target: 8, max: 12, label: 'Sorceries' },
+ Artifact: { min: 6, target: 10, max: 15, label: 'Artifacts' },
+ Enchantment: { min: 5, target: 8, max: 12, label: 'Enchantments' },
+ Planeswalker: { min: 0, target: 2, max: 5, label: 'Planeswalkers' },
+};
+
+/** Map a card's type_line to one of the canonical groups. */
+function getCardTypeGroup(typeLine) {
+ if (!typeLine) return 'Other';
+ const t = typeLine.toLowerCase();
+ if (t.includes('land')) return 'Land';
+ if (t.includes('creature')) return 'Creature';
+ if (t.includes('planeswalker')) return 'Planeswalker';
+ if (t.includes('instant')) return 'Instant';
+ if (t.includes('sorcery')) return 'Sorcery';
+ if (t.includes('artifact')) return 'Artifact';
+ if (t.includes('enchantment')) return 'Enchantment';
+ return 'Other';
+}
+
+/** Return a ratio progress status string. */
+function ratioStatus(count, min, max) {
+ if (count === 0 && min === 0) return 'neutral';
+ if (count < min) return 'low';
+ if (count > max) return 'over';
+ if (count >= min && count <= max) return 'ok';
+ return 'warn';
+}
+
+/* ── Debounce Helper ──────────────────────────────────────── */
+
+function debounce(fn, ms) {
+ let timer;
+ return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
+}
+
+/* ── Color Identity Rendering ────────────────────────────── */
+
+const COLOR_NAMES = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' };
+
+function buildColorChip(color) {
+ const chip = el('span', `deck-color-chip deck-chip-${color}`);
+ chip.textContent = color;
+ chip.title = COLOR_NAMES[color] || color;
+ return chip;
+}
+
+function parseColorIdentity(raw) {
+ if (!raw) return [];
+ if (Array.isArray(raw)) return raw;
+ // "WUBRG" string or comma-separated
+ if (raw.includes(',')) return raw.split(',').map(c => c.trim().toUpperCase());
+ return raw.toUpperCase().split('').filter(c => 'WUBRG'.includes(c));
+}
+
+/* ── Type Badge ───────────────────────────────────────────── */
+
+function buildTypeBadge(typeLine) {
+ const group = getCardTypeGroup(typeLine || '');
+ const badge = el('span', `deck-type-badge deck-type-${group}`, group[0] === 'P' && group !== 'Planeswalker' ? group : group.slice(0, 4));
+ badge.title = typeLine || group;
+ return badge;
+}
+
+/* ── Main DeckBuilder Application ────────────────────────── */
+
+const DeckBuilder = {
+ /* State */
+ deckId: null,
+ deckName: '',
+ deckCards: [],
+ deckInfo: null,
+ analysis: null,
+ allDecks: [],
+ searchResults: [],
+ searchQuery: '',
+ searchType: '',
+ searchCmcMin: null,
+ searchCmcMax: null,
+ searchColors: new Set(),
+ edhRecData: [],
+ edhRecOwnedOnly: false,
+ commanderScryfallId: null,
+ newCommanderScryfallId: null,
+ newCommanderName: '',
+ _saveTimer: null,
+ _activeTypeFilter: null,
+ pplxConfigured: false,
+ aiGenCommanderName: '',
+ _aiGenSuggestions: [],
+ _aiGenActiveIndex: -1,
+ _lastGenResult: null,
+
+ /* ── Init ─────────────────────────────────────────────── */
+
+ async init() {
+ ToastManager.init();
+ CardPreview.init();
+ ConfirmDialog.init();
+
+ this._bindTopBar();
+ this._bindSearch();
+ this._bindQuickAdd();
+ this._bindNewDeckModal();
+ this._bindCollapsible();
+ this._bindRightColumn();
+ this._bindAIPanels();
+
+ await this.loadDecks();
+ this._checkPplxStatus();
+
+ // Restore last deck from localStorage
+ const lastId = localStorage.getItem('deck-builder-last-deck');
+ if (lastId) {
+ this.deckId = parseInt(lastId, 10);
+ qs('#deck-selector').value = this.deckId;
+ await this.loadDeck(this.deckId);
+ }
+ },
+
+ /* ── Top Bar Bindings ─────────────────────────────────── */
+
+ _bindTopBar() {
+ const selector = qs('#deck-selector');
+ selector.addEventListener('change', async (e) => {
+ const id = parseInt(e.target.value, 10);
+ if (!isNaN(id)) {
+ this.deckId = id;
+ await this.loadDeck(id);
+ }
+ });
+
+ const nameInput = qs('#deck-name-input');
+ nameInput.addEventListener('input', () => this._scheduleSaveName());
+ nameInput.addEventListener('change', () => this._saveDeckName());
+
+ qs('#deck-new-btn').addEventListener('click', () => this._openNewDeckModal());
+ qs('#deck-empty-new-btn').addEventListener('click', () => this._openNewDeckModal());
+
+ qs('#deck-export-btn').addEventListener('click', () => this._exportDck());
+ qs('#deck-export-sim-btn').addEventListener('click', () => this._exportDckToSim());
+ qs('#deck-sim-btn').addEventListener('click', () => this._exportToSim());
+ if (qs('#deck-export-game-btn')) {
+ qs('#deck-export-game-btn').addEventListener('click', () => this._exportToLanClient());
+ }
+ qs('#deck-import-btn').addEventListener('click', () => this._openImportModal());
+ qs('#deck-delete-btn').addEventListener('click', () => this._deleteDeck());
+ qs('#deck-clear-all-btn').addEventListener('click', () => this._clearAllDecks());
+
+ // Import modal wiring
+ this._initImportModal();
+
+ // Commander portrait click to change commander
+ qs('#deck-commander-portrait').addEventListener('click', () => {
+ if (!this.deckId) { ToastManager.show('Create a deck first.', 'warning'); return; }
+ this._openNewDeckModal(true);
+ });
+ },
+
+ _scheduleSaveName() {
+ clearTimeout(this._saveTimer);
+ this._saveTimer = setTimeout(() => this._saveDeckName(), 1200);
+ },
+
+ async _saveDeckName() {
+ if (!this.deckId) return;
+ const name = qs('#deck-name-input').value.trim();
+ if (!name || name === this.deckName) return;
+ try {
+ await apiPut(`/api/decks/${this.deckId}`, { name });
+ this.deckName = name;
+ this._refreshDeckSelector();
+ } catch (err) {
+ ToastManager.show(`Failed to rename deck: ${err.message}`, 'error');
+ }
+ },
+
+ async _deleteDeck() {
+ if (!this.deckId) { ToastManager.show('No deck selected.', 'warning'); return; }
+ const ok = await ConfirmDialog.show({
+ title: 'Delete Deck',
+ message: 'Are you sure you want to delete "' + (this.deckName || 'this deck') + '"? This cannot be undone.',
+ okLabel: 'Delete',
+ });
+ if (!ok) return;
+ try {
+ await apiDelete('/api/decks/' + this.deckId);
+ ToastManager.show('Deck deleted.', 'success');
+ this.deckId = null;
+ this.deckInfo = null;
+ this.deckName = '';
+ this.deckCards = [];
+ localStorage.removeItem('deck-builder-last-deck');
+ qs('#deck-delete-btn').style.display = 'none';
+ await this.loadDecks();
+ qs('#deck-selector').value = '';
+ qs('#deck-name-input').value = '';
+ qs('#deck-card-groups').innerHTML = '';
+ } catch (err) {
+ ToastManager.show('Failed to delete deck: ' + err.message, 'error');
+ }
+ },
+
+ async _clearAllDecks() {
+ if (!this.allDecks.length) { ToastManager.show('No decks to clear.', 'warning'); return; }
+ const ok = await ConfirmDialog.show({
+ title: 'Clear All Decks',
+ message: 'Delete all ' + this.allDecks.length + ' decks? This cannot be undone.',
+ okLabel: 'Delete All',
+ });
+ if (!ok) return;
+ try {
+ await apiDelete('/api/decks');
+ ToastManager.show('All decks cleared.', 'success');
+ this.deckId = null;
+ this.deckInfo = null;
+ this.deckName = '';
+ this.deckCards = [];
+ this.allDecks = [];
+ localStorage.removeItem('deck-builder-last-deck');
+ qs('#deck-delete-btn').style.display = 'none';
+ this._renderDeckSelector();
+ qs('#deck-selector').value = '';
+ qs('#deck-name-input').value = '';
+ qs('#deck-card-groups').innerHTML = '';
+ } catch (err) {
+ ToastManager.show('Failed to clear decks: ' + err.message, 'error');
+ }
+ },
+
+ /* ── Deck Loading ─────────────────────────────────────── */
+
+ async loadDecks() {
+ try {
+ const resp = await apiGet('/api/decks');
+ this.allDecks = Array.isArray(resp) ? resp : (resp.decks || []);
+ this._renderDeckSelector();
+ } catch (_) {
+ this.allDecks = [];
+ }
+ },
+
+ _renderDeckSelector() {
+ const sel = qs('#deck-selector');
+ const current = sel.value;
+ sel.innerHTML = '— Select Deck — ';
+ for (const d of this.allDecks) {
+ const opt = document.createElement('option');
+ opt.value = d.id;
+ opt.textContent = `${d.name}${d.commander_name ? ' (' + d.commander_name + ')' : ''}`;
+ sel.appendChild(opt);
+ }
+ sel.value = current;
+ },
+
+ _refreshDeckSelector() {
+ const idx = this.allDecks.findIndex(d => d.id === this.deckId);
+ if (idx >= 0) {
+ this.allDecks[idx].name = this.deckName;
+ this._renderDeckSelector();
+ qs('#deck-selector').value = this.deckId;
+ }
+ },
+
+ async loadDeck(id) {
+ if (!id) return;
+ this.deckId = id;
+ localStorage.setItem('deck-builder-last-deck', id);
+
+ // Show loading skeletons
+ this._showCardGroupsLoading();
+
+ try {
+ // Parallel fetch: deck info + cards
+ const [info, cards] = await Promise.all([
+ apiGet(`/api/decks/${id}`),
+ apiGet(`/api/decks/${id}/cards`),
+ ]);
+ this.deckInfo = info;
+ this.deckName = info.name;
+ this.deckCards = Array.isArray(cards) ? cards : (cards.cards || []);
+ this.commanderScryfallId = this.deckCards.find(c => c.is_commander)?.scryfall_id || null;
+
+ this._renderTopBar();
+ this._renderCardGroups();
+ this._showDeckControls();
+
+ // Load analysis for ratio dashboard (non-blocking)
+ this._loadAnalysis();
+
+ // Refresh search results with new deck context
+ if (this.searchQuery) this._runSearch();
+
+ // Enable AI research button
+ this._updateAIResearchBtn();
+
+ } catch (err) {
+ ToastManager.show(`Failed to load deck: ${err.message}`, 'error');
+ this._renderCardGroups(); // show empty state
+ }
+ },
+
+ _renderTopBar() {
+ const info = this.deckInfo;
+ if (!info) return;
+
+ qs('#deck-name-input').value = info.name || '';
+
+ // Commander label
+ const cmdLabel = qs('#deck-commander-label');
+ if (info.commander_name) {
+ cmdLabel.textContent = info.commander_name;
+ } else {
+ cmdLabel.textContent = 'No commander selected';
+ }
+
+ // Commander portrait
+ const img = qs('#deck-commander-img');
+ const placeholder = qs('.deck-commander-portrait-placeholder');
+ const portrait = qs('#deck-commander-portrait');
+ const cmdCard = this.deckCards.find(c => c.is_commander);
+ if (cmdCard && cmdCard.scryfall_id) {
+ img.src = `https://api.scryfall.com/cards/${cmdCard.scryfall_id}?format=image&version=small`;
+ img.style.display = 'block';
+ placeholder.style.display = 'none';
+ this.commanderScryfallId = cmdCard.scryfall_id;
+
+ // Attach card preview hover to commander label and portrait
+ CardPreview.attach(cmdLabel, cmdCard.scryfall_id);
+ CardPreview.attach(portrait, cmdCard.scryfall_id);
+ } else {
+ img.style.display = 'none';
+ placeholder.style.display = '';
+ }
+
+ // Color chips
+ const chipsEl = qs('#deck-color-chips');
+ chipsEl.innerHTML = '';
+ const colors = parseColorIdentity(info.color_identity);
+ for (const c of colors) {
+ chipsEl.appendChild(buildColorChip(c));
+ }
+
+ // Total count
+ this._updateCountDisplay();
+
+ // Selector
+ qs('#deck-selector').value = info.id;
+ },
+
+ _updateCountDisplay() {
+ const total = this.deckCards.reduce((s, c) => s + (c.quantity || 1), 0);
+ const countEl = qs('#deck-total-count');
+ countEl.textContent = total;
+ countEl.className = 'deck-count-num' + (total > 100 ? ' over-limit' : total === 100 ? ' at-limit' : '');
+
+ const footerTotal = qs('#deck-footer-total');
+ if (footerTotal) footerTotal.textContent = total;
+
+ const owned = this.deckCards
+ .filter(c => (c.owned_quantity || 0) >= (c.quantity || 1))
+ .reduce((s, c) => s + (c.quantity || 1), 0);
+ const footerOwned = qs('#deck-footer-owned');
+ if (footerOwned) footerOwned.textContent = owned;
+
+ const nonLands = this.deckCards.filter(c => getCardTypeGroup(c.type_line) !== 'Land');
+ const avgCmc = nonLands.length > 0
+ ? (nonLands.reduce((s, c) => s + (c.cmc || 0) * (c.quantity || 1), 0) /
+ nonLands.reduce((s, c) => s + (c.quantity || 1), 0)).toFixed(1)
+ : '0.0';
+ const footerCmc = qs('#deck-footer-avg-cmc');
+ if (footerCmc) footerCmc.textContent = avgCmc;
+ },
+
+ _showDeckControls() {
+ qs('#deck-quickadd-row').style.display = '';
+ qs('#deck-stats-footer').style.display = '';
+ qs('#deck-delete-btn').style.display = this.deckId ? '' : 'none';
+ },
+
+ /* ── Card Groups Rendering ────────────────────────────── */
+
+ _showCardGroupsLoading() {
+ const container = qs('#deck-card-groups');
+ container.innerHTML = '';
+ for (let i = 0; i < 5; i++) {
+ const skel = el('div', 'deck-skeleton deck-skeleton-row');
+ container.appendChild(skel);
+ }
+ },
+
+ _renderCardGroups() {
+ const container = qs('#deck-card-groups');
+ container.innerHTML = '';
+
+ if (!this.deckId || !this.deckCards || this.deckCards.length === 0) {
+ const empty = el('div', 'deck-empty-state deck-empty-state-lg');
+ const icon = el('div', 'deck-empty-icon', '⚔');
+ const text = el('div', 'deck-empty-text', this.deckId
+ ? 'Your deck is empty. Add cards from the search panel.'
+ : 'Create or select a deck to start building.');
+ const btn = el('button', 'deck-btn deck-btn-accent');
+ btn.textContent = '+ New Deck';
+ btn.addEventListener('click', () => this._openNewDeckModal());
+ empty.appendChild(icon);
+ empty.appendChild(text);
+ if (!this.deckId) empty.appendChild(btn);
+ container.appendChild(empty);
+ return;
+ }
+
+ // Separate commander(s) from the rest
+ const commanders = this.deckCards.filter(c => c.is_commander);
+ const nonCommanders = this.deckCards.filter(c => !c.is_commander);
+
+ // Render commander section first
+ if (commanders.length > 0) {
+ const cmdSection = this._buildSection('Commander', commanders, true);
+ container.appendChild(cmdSection);
+ }
+
+ // Group remaining cards by type
+ const groups = {};
+ for (const card of nonCommanders) {
+ const group = getCardTypeGroup(card.type_line);
+ if (!groups[group]) groups[group] = [];
+ groups[group].push(card);
+ }
+
+ const ORDER = ['Creature', 'Land', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Other'];
+
+ for (const type of ORDER) {
+ if (!groups[type] || groups[type].length === 0) continue;
+ const sectionEl = this._buildSection(type, groups[type]);
+ container.appendChild(sectionEl);
+ }
+
+ // Update meta label
+ const metaEl = qs('#deck-card-list-meta');
+ const total = this.deckCards.reduce((s, c) => s + (c.quantity || 1), 0);
+ metaEl.textContent = `${this.deckCards.length} unique · ${total} total`;
+
+ this._updateCountDisplay();
+ },
+
+ _buildSection(type, cards, isCommanderSection) {
+ const target = isCommanderSection ? null : EDH_TARGETS[type];
+ const count = cards.reduce((s, c) => s + (c.quantity || 1), 0);
+
+ const section = el('div', `deck-section${isCommanderSection ? ' deck-section-commander' : ''}`);
+ section.dataset.type = type;
+
+ // Section header
+ const header = el('div', 'deck-section-header');
+
+ const arrow = el('span', 'deck-section-toggle', '▼');
+
+ const sectionLabel = isCommanderSection ? '⚔ Commander' : (type === 'Other' ? 'Other / Misc' : `${type}s`);
+ const nameLbl = el('span', 'deck-section-name', sectionLabel);
+
+ let countText = `${count}`;
+ if (target) countText += ` / ~${target.target}`;
+ const countLbl = el('span', 'deck-section-count', countText);
+
+ header.appendChild(arrow);
+ header.appendChild(nameLbl);
+ header.appendChild(countLbl);
+
+ // Progress bar (skip for commander section)
+ if (!isCommanderSection) {
+ const progress = el('div', 'deck-section-progress');
+ const fill = el('div', 'deck-section-progress-fill');
+ if (target) {
+ const pct = Math.min(100, Math.round((count / target.max) * 100));
+ fill.style.width = `${pct}%`;
+ fill.classList.add(`status-${ratioStatus(count, target.min, target.max)}`);
+ } else {
+ fill.style.width = '50%';
+ fill.classList.add('status-neutral');
+ }
+ progress.appendChild(fill);
+ header.appendChild(progress);
+ }
+
+ // Body
+ const body = el('div', 'deck-section-body');
+ // Set an explicit max-height so CSS transition works
+ body.style.maxHeight = '9999px';
+
+ for (const card of cards) {
+ body.appendChild(this._buildCardRow(card));
+ }
+
+ header.addEventListener('click', () => {
+ const collapsed = body.classList.toggle('collapsed');
+ arrow.classList.toggle('collapsed', collapsed);
+ if (!collapsed) {
+ body.style.maxHeight = '9999px';
+ } else {
+ body.style.maxHeight = '0px';
+ }
+ });
+
+ section.appendChild(header);
+ section.appendChild(body);
+ return section;
+ },
+
+ _buildCardRow(card) {
+ const isCmd = card.is_commander;
+ const row = el('div', `deck-card-row${isCmd ? ' is-commander' : ''}`);
+ row.dataset.cardId = card.id;
+
+ // Owned dot
+ const ownedDot = el('span', `deck-card-owned-dot${(card.owned_quantity || 0) > 0 ? '' : ' unowned'}`);
+ ownedDot.title = `Owned: ${card.owned_quantity || 0}`;
+ row.appendChild(ownedDot);
+
+ // Mana cost (before name)
+ if (card.mana_cost) {
+ const manaEl = buildManaCost(card.mana_cost);
+ manaEl.className = 'deck-card-row-mana';
+ row.appendChild(manaEl);
+ }
+
+ // Name (hoverable for card preview)
+ const nameEl = el('span', 'deck-card-row-name', card.card_name);
+ const hoverSfId = card.scryfall_id || (isCmd ? this.commanderScryfallId : null);
+ if (hoverSfId) CardPreview.attach(nameEl, hoverSfId);
+ row.appendChild(nameEl);
+
+ // Role chip (only for non-commander)
+ if (!isCmd) {
+ const role = card.role_tag || '';
+ const roleChip = el('span', 'deck-card-role-chip', role || 'role');
+ roleChip.title = 'Click to set role';
+ roleChip.addEventListener('click', () => this._setRoleTag(card));
+ row.appendChild(roleChip);
+ }
+
+ // Quantity controls (not for commander)
+ if (!isCmd) {
+ const qtyWrap = el('div', 'deck-qty-controls');
+ const minusBtn = el('button', 'deck-qty-btn', '−');
+ const qtyNum = el('span', 'deck-qty-num', String(card.quantity || 1));
+ const plusBtn = el('button', 'deck-qty-btn', '+');
+
+ minusBtn.addEventListener('click', () => this._changeQty(card, -1));
+ plusBtn.addEventListener('click', () => this._changeQty(card, +1));
+
+ qtyWrap.appendChild(minusBtn);
+ qtyWrap.appendChild(qtyNum);
+ qtyWrap.appendChild(plusBtn);
+ row.appendChild(qtyWrap);
+ }
+
+ // Commander badge (after other controls, at end of row)
+ if (isCmd) {
+ const cmdBadge = el('span', 'deck-card-commander-badge', 'Commander');
+ row.appendChild(cmdBadge);
+ }
+
+ // Remove button
+ const removeBtn = el('button', 'deck-card-remove-btn', '✕');
+ removeBtn.title = 'Remove from deck';
+ removeBtn.addEventListener('click', () => this._removeCard(card));
+ row.appendChild(removeBtn);
+
+ return row;
+ },
+
+ async _changeQty(card, delta) {
+ const newQty = (card.quantity || 1) + delta;
+ if (newQty < 1) {
+ await this._removeCard(card);
+ return;
+ }
+ try {
+ await apiPatch(`/api/decks/${this.deckId}/cards/${card.id}`, { quantity: newQty });
+ card.quantity = newQty;
+ // Update the row's quantity display without full re-render
+ const row = qs(`[data-card-id="${card.id}"]`);
+ if (row) {
+ const num = qs('.deck-qty-num', row);
+ if (num) num.textContent = newQty;
+ }
+ this._updateCountDisplay();
+ this._updateRatioBar(getCardTypeGroup(card.type_line));
+ } catch (err) {
+ ToastManager.show(`Failed to update quantity: ${err.message}`, 'error');
+ }
+ },
+
+ async _removeCard(card) {
+ try {
+ await apiDelete(`/api/decks/${this.deckId}/cards/${card.id}`);
+ this.deckCards = this.deckCards.filter(c => c.id !== card.id);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ ToastManager.show(`Removed ${card.card_name}`, 'info');
+ } catch (err) {
+ ToastManager.show(`Failed to remove: ${err.message}`, 'error');
+ }
+ },
+
+ async _setRoleTag(card) {
+ const roles = ['ramp', 'draw', 'removal', 'wipe', 'tutor', 'protection', 'win-con', 'synergy', 'value', 'beatdown', ''];
+ const current = card.role_tag || '';
+ const currentIdx = roles.indexOf(current);
+ const next = roles[(currentIdx + 1) % roles.length];
+
+ try {
+ await apiPatch(`/api/decks/${this.deckId}/cards/${card.id}`, { role_tag: next });
+ card.role_tag = next;
+ const row = qs(`[data-card-id="${card.id}"]`);
+ if (row) {
+ const chip = qs('.deck-card-role-chip', row);
+ if (chip) chip.textContent = next || 'role';
+ }
+ } catch (err) {
+ ToastManager.show(`Failed to set role: ${err.message}`, 'error');
+ }
+ },
+
+ /* ── Adding Cards ─────────────────────────────────────── */
+
+ async addCardToDeck(scryfallId, cardName, quantity = 1, isCommander = false) {
+ if (!this.deckId) {
+ ToastManager.show('Select or create a deck first.', 'warning');
+ return;
+ }
+ try {
+ await apiPost(`/api/decks/${this.deckId}/cards`, {
+ scryfall_id: scryfallId,
+ card_name: cardName || '',
+ quantity,
+ is_commander: isCommander ? 1 : 0,
+ });
+ // Reload deck cards
+ const _cardsResp = await apiGet(`/api/decks/${this.deckId}/cards`);
+ this.deckCards = Array.isArray(_cardsResp) ? _cardsResp : (_cardsResp.cards || []);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ ToastManager.show(`Added ${cardName}`, 'success');
+ // Refresh search results to update in-deck status
+ if (this.searchQuery) this._refreshSearchInDeckStatus();
+ } catch (err) {
+ if (err.message.includes('already in deck') || err.message.includes('already exists')) {
+ ToastManager.show(`${cardName} is already in your deck`, 'warning');
+ } else {
+ ToastManager.show(`Failed to add ${cardName}: ${err.message}`, 'error');
+ }
+ }
+ },
+
+ _refreshSearchInDeckStatus() {
+ const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id));
+ qsa('.deck-search-row', qs('#deck-search-results')).forEach(row => {
+ const id = row.dataset.scryfallId;
+ if (inDeckIds.has(id)) {
+ row.classList.add('in-deck');
+ const addBtn = qs('.deck-search-row-add', row);
+ if (addBtn) addBtn.textContent = '✓';
+ } else {
+ row.classList.remove('in-deck');
+ const addBtn = qs('.deck-search-row-add', row);
+ if (addBtn) addBtn.textContent = '+';
+ }
+ });
+ },
+
+ /* ── Collection Search ────────────────────────────────── */
+
+ _bindSearch() {
+ const bar = qs('#deck-search-bar');
+ const typeFilter = qs('#deck-type-filter');
+ const cmcMin = qs('#deck-cmc-min');
+ const cmcMax = qs('#deck-cmc-max');
+
+ const debouncedSearch = debounce(() => this._runSearch(), 350);
+
+ bar.addEventListener('input', (e) => {
+ this.searchQuery = e.target.value.trim();
+ debouncedSearch();
+ });
+
+ typeFilter.addEventListener('change', (e) => {
+ this.searchType = e.target.value;
+ this._runSearch();
+ });
+
+ cmcMin.addEventListener('change', (e) => {
+ this.searchCmcMin = e.target.value !== '' ? parseInt(e.target.value, 10) : null;
+ debouncedSearch();
+ });
+
+ cmcMax.addEventListener('change', (e) => {
+ this.searchCmcMax = e.target.value !== '' ? parseInt(e.target.value, 10) : null;
+ debouncedSearch();
+ });
+
+ // Color filter pills
+ qs('#deck-color-filter').addEventListener('click', (e) => {
+ const btn = e.target.closest('.deck-color-pip');
+ if (!btn) return;
+ const color = btn.dataset.color;
+ if (this.searchColors.has(color)) {
+ this.searchColors.delete(color);
+ btn.classList.remove('active');
+ } else {
+ this.searchColors.add(color);
+ btn.classList.add('active');
+ }
+ debouncedSearch();
+ });
+
+ qs('#deck-add-all-owned-btn').addEventListener('click', () => this._addAllMatching());
+ },
+
+ async _runSearch() {
+ const resultsEl = qs('#deck-search-results');
+
+ if (!this.searchQuery && !this.searchType && this.searchColors.size === 0 && this.searchCmcMin === null && this.searchCmcMax === null) {
+ resultsEl.innerHTML = '';
+ const empty = el('div', 'deck-empty-state');
+ empty.appendChild(el('div', 'deck-empty-icon', '🃏'));
+ empty.appendChild(el('div', 'deck-empty-text', 'Search your collection to add cards'));
+ resultsEl.appendChild(empty);
+ qs('#deck-search-count').textContent = '0 results';
+ qs('#deck-add-all-owned-btn').style.display = 'none';
+ return;
+ }
+
+ // Show loading
+ resultsEl.innerHTML = '';
+
+ try {
+ let params = ['pageSize=200'];
+ if (this.searchQuery) params.push(`q=${encodeURIComponent(this.searchQuery)}`);
+ if (this.deckId) params.push(`deck_id=${this.deckId}`);
+ if (this.searchType) params.push(`types=${encodeURIComponent(this.searchType)}`);
+ if (this.searchCmcMin !== null) params.push(`cmcMin=${this.searchCmcMin}`);
+ if (this.searchCmcMax !== null) params.push(`cmcMax=${this.searchCmcMax}`);
+ if (this.searchColors.size > 0) params.push(`colors=${[...this.searchColors].join(',')}`);
+
+ const url = `/api/collection?${params.join('&')}`;
+ const _collResp = await apiGet(url);
+ let cards = Array.isArray(_collResp) ? _collResp : (_collResp.items || []);
+
+ // Client-side fallback filters
+ if (this.searchType) {
+ cards = cards.filter(c => getCardTypeGroup(c.type_line || '') === this.searchType);
+ }
+ if (this.searchCmcMin !== null) {
+ cards = cards.filter(c => (c.cmc || 0) >= this.searchCmcMin);
+ }
+ if (this.searchCmcMax !== null) {
+ cards = cards.filter(c => (c.cmc || 0) <= this.searchCmcMax);
+ }
+
+ this.searchResults = cards;
+ this._renderSearchResults(cards);
+
+ } catch (err) {
+ resultsEl.innerHTML = ``;
+ }
+ },
+
+ _renderSearchResults(cards) {
+ const resultsEl = qs('#deck-search-results');
+ resultsEl.innerHTML = '';
+
+ qs('#deck-search-count').textContent = `${cards.length} result${cards.length !== 1 ? 's' : ''}`;
+
+ if (cards.length === 0) {
+ const empty = el('div', 'deck-empty-state');
+ empty.appendChild(el('div', 'deck-empty-icon', '🔍'));
+ empty.appendChild(el('div', 'deck-empty-text', 'No cards found in your collection'));
+ resultsEl.appendChild(empty);
+ qs('#deck-add-all-owned-btn').style.display = 'none';
+ return;
+ }
+
+ const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id));
+ let hasOwned = false;
+
+ for (const card of cards) {
+ const inDeck = inDeckIds.has(card.scryfall_id);
+ const owned = (card.quantity || card.owned_quantity || 0) > 0;
+ if (owned) hasOwned = true;
+
+ const row = el('div', `deck-search-row${inDeck ? ' in-deck' : ''}`);
+ row.dataset.scryfallId = card.scryfall_id;
+
+ const nameEl = el('span', 'deck-search-row-name', card.card_name || card.name);
+ CardPreview.attach(nameEl, card.scryfall_id);
+
+ const manaEl = buildManaCost(card.mana_cost);
+ manaEl.className = 'deck-search-row-mana';
+
+ const ownedEl = el('span', `deck-search-row-owned${owned ? ' has-owned' : ''}`,
+ owned ? `×${card.quantity || card.owned_quantity}` : '—');
+ ownedEl.title = `Owned: ${card.quantity || card.owned_quantity || 0}`;
+
+ const addBtn = el('button', 'deck-search-row-add', inDeck ? '✓' : '+');
+ addBtn.title = inDeck ? 'Already in deck' : 'Add to deck';
+ addBtn.addEventListener('click', () => {
+ this.addCardToDeck(card.scryfall_id, card.card_name || card.name);
+ });
+
+ row.appendChild(nameEl);
+ row.appendChild(manaEl);
+ row.appendChild(ownedEl);
+ row.appendChild(addBtn);
+ resultsEl.appendChild(row);
+ }
+
+ // Show bulk-add button if there are owned cards
+ const bulkBtn = qs('#deck-add-all-owned-btn');
+ bulkBtn.style.display = hasOwned ? '' : 'none';
+ },
+
+ async _addAllMatching() {
+ const owned = this.searchResults.filter(c => (c.quantity || c.owned_quantity || 0) > 0);
+ if (owned.length === 0) {
+ ToastManager.show('No owned cards in search results.', 'warning');
+ return;
+ }
+
+ const confirmed = await ConfirmDialog.show({
+ title: 'Add All Owned',
+ message: `Add all ${owned.length} owned matching card(s) to your deck?`,
+ changes: [{ label: 'Cards to add', before: 0, after: owned.length }],
+ });
+ if (!confirmed) return;
+
+ let added = 0, skipped = 0;
+ for (const card of owned) {
+ try {
+ await apiPost(`/api/decks/${this.deckId}/cards`, {
+ scryfall_id: card.scryfall_id,
+ card_name: card.card_name || card.name || '',
+ quantity: 1,
+ });
+ added++;
+ } catch (_) {
+ skipped++;
+ }
+ }
+
+ const _cr1 = await apiGet(`/api/decks/${this.deckId}/cards`);
+ this.deckCards = Array.isArray(_cr1) ? _cr1 : (_cr1.cards || []);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ this._refreshSearchInDeckStatus();
+ ToastManager.show(`Bulk added ${added} cards${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success');
+ },
+
+ /* ── Quick Add ────────────────────────────────────────── */
+
+ _bindQuickAdd() {
+ const input = qs('#deck-quickadd-input');
+ const dropdown = qs('#deck-quickadd-dropdown');
+ const addBtn = qs('#deck-quickadd-btn');
+
+ let _activeIndex = -1;
+ let _suggestions = [];
+
+ const debouncedAC = debounce(async (q) => {
+ if (!q || q.length < 2) { dropdown.style.display = 'none'; return; }
+ const names = await scryfallAutocomplete(q);
+ _suggestions = names.slice(0, 8);
+ _activeIndex = -1;
+ this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown);
+ }, 280);
+
+ input.addEventListener('input', (e) => debouncedAC(e.target.value.trim()));
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowDown') {
+ _activeIndex = Math.min(_activeIndex + 1, _suggestions.length - 1);
+ this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown);
+ e.preventDefault();
+ } else if (e.key === 'ArrowUp') {
+ _activeIndex = Math.max(_activeIndex - 1, -1);
+ this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown);
+ e.preventDefault();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const name = _activeIndex >= 0 ? _suggestions[_activeIndex] : input.value.trim();
+ if (name) this._quickAddByName(name, input, dropdown);
+ } else if (e.key === 'Escape') {
+ dropdown.style.display = 'none';
+ }
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!input.contains(e.target) && !dropdown.contains(e.target)) {
+ dropdown.style.display = 'none';
+ }
+ });
+
+ addBtn.addEventListener('click', () => {
+ const name = input.value.trim();
+ if (name) this._quickAddByName(name, input, dropdown);
+ });
+
+ dropdown.addEventListener('click', (e) => {
+ const item = e.target.closest('.deck-quickadd-item');
+ if (item) {
+ const name = item.dataset.name;
+ this._quickAddByName(name, input, dropdown);
+ }
+ });
+ },
+
+ _renderQuickAddDropdown(suggestions, activeIndex, dropdown) {
+ dropdown.innerHTML = '';
+ if (suggestions.length === 0) { dropdown.style.display = 'none'; return; }
+ for (let i = 0; i < suggestions.length; i++) {
+ const name = suggestions[i];
+ const item = el('div', `deck-quickadd-item${i === activeIndex ? ' active' : ''}`);
+ item.dataset.name = name;
+ item.appendChild(el('span', 'deck-quickadd-item-name', name));
+ dropdown.appendChild(item);
+ }
+ dropdown.style.display = 'block';
+ },
+
+ async _quickAddByName(name, input, dropdown) {
+ if (!this.deckId) {
+ ToastManager.show('Create or select a deck first.', 'warning');
+ return;
+ }
+ dropdown.style.display = 'none';
+ input.value = '';
+
+ // Scryfall fuzzy lookup to get the scryfall_id
+ const card = await scryfallFuzzySearch(name);
+ if (!card) {
+ ToastManager.show(`Card "${name}" not found on Scryfall`, 'error');
+ return;
+ }
+ await this.addCardToDeck(card.id, card.name);
+ },
+
+ /* ── Analysis & Ratio Dashboard ──────────────────────── */
+
+ async _loadAnalysis() {
+ if (!this.deckId) return;
+ try {
+ this.analysis = await apiGet(`/api/decks/${this.deckId}/analysis`);
+ this._renderRatioDashboard();
+ this._renderShortfalls();
+ } catch (_) {
+ this._renderRatioDashboard(true);
+ }
+ },
+
+ _renderRatioDashboard(failed = false) {
+ const barsEl = qs('#deck-ratio-bars');
+ barsEl.innerHTML = '';
+
+ if (failed || !this.analysis) {
+ barsEl.appendChild(el('div', 'deck-ratio-placeholder', failed ? 'Analysis unavailable' : 'Load a deck to see ratios'));
+ return;
+ }
+
+ const counts = this.analysis.counts_by_type || {};
+
+ for (const [type, tgt] of Object.entries(EDH_TARGETS)) {
+ const count = counts[type] || 0;
+ const maxRange = tgt.max;
+ const status = ratioStatus(count, tgt.min, tgt.max);
+ const pct = maxRange > 0 ? Math.min(100, (count / maxRange) * 100) : 0;
+
+ const row = el('div', `deck-ratio-bar-row${this._activeTypeFilter === type ? ' active-filter' : ''}`);
+ row.dataset.type = type;
+ row.title = `${tgt.label}: ${count} (target: ${tgt.min}–${tgt.max})`;
+
+ row.addEventListener('click', () => {
+ if (this._activeTypeFilter === type) {
+ this._activeTypeFilter = null;
+ qs('#deck-type-filter').value = '';
+ } else {
+ this._activeTypeFilter = type;
+ qs('#deck-type-filter').value = type;
+ }
+ this._runSearch();
+ this._renderRatioDashboard();
+ });
+
+ const label = el('span', 'deck-ratio-bar-label', tgt.label);
+ const track = el('div', 'deck-ratio-bar-track');
+ const fill = el('div', `deck-ratio-bar-fill status-${status}`);
+ fill.style.width = `${pct}%`;
+
+ // Min/max indicator marks
+ if (maxRange > 0) {
+ const minMark = el('div', 'deck-ratio-bar-target-min');
+ minMark.style.left = `${(tgt.min / maxRange) * 100}%`;
+ const maxMark = el('div', 'deck-ratio-bar-target-max');
+ maxMark.style.left = `${Math.min(100, (tgt.max / maxRange) * 100)}%`;
+ track.appendChild(minMark);
+ track.appendChild(maxMark);
+ }
+
+ track.appendChild(fill);
+
+ const nums = el('span', 'deck-ratio-bar-nums');
+ const cur = el('span', 'deck-ratio-current', String(count));
+ nums.appendChild(cur);
+ nums.appendChild(document.createTextNode(` / ${tgt.target}`));
+
+ row.appendChild(label);
+ row.appendChild(track);
+ row.appendChild(nums);
+ barsEl.appendChild(row);
+ }
+ },
+
+ _updateRatioBar(type) {
+ // Lightweight update: recalculate count for one type and re-draw
+ const counts = {};
+ for (const card of this.deckCards) {
+ const g = getCardTypeGroup(card.type_line);
+ counts[g] = (counts[g] || 0) + (card.quantity || 1);
+ }
+ if (this.analysis) {
+ this.analysis.counts_by_type = counts;
+ this._renderRatioDashboard();
+ }
+ },
+
+ _renderShortfalls() {
+ const sfEl = qs('#deck-shortfalls');
+ sfEl.innerHTML = '';
+ const fixActions = qs('#deck-fix-actions');
+
+ if (!this.analysis) {
+ sfEl.appendChild(el('div', 'deck-ratio-placeholder', 'No analysis available'));
+ fixActions.style.display = 'none';
+ return;
+ }
+
+ const counts = this.analysis.counts_by_type || {};
+ let hasIssues = false;
+
+ for (const [type, tgt] of Object.entries(EDH_TARGETS)) {
+ const count = counts[type] || 0;
+ const status = ratioStatus(count, tgt.min, tgt.max);
+ if (status === 'ok' || status === 'neutral') continue;
+
+ hasIssues = true;
+ const row = el('div', 'deck-shortfall-row');
+ const icon = el('span', 'deck-shortfall-icon', status === 'low' ? '⬇' : status === 'over' ? '⬆' : '⚠');
+ const text = el('span', 'deck-shortfall-text', `${tgt.label} (${count})`);
+ const delta = tgt.target - count;
+ const deltaEl = el('span', `deck-shortfall-delta ${delta > 0 ? 'needs-more' : 'over-count'}`);
+ deltaEl.textContent = delta > 0 ? `+${delta} needed` : `${delta} over`;
+
+ row.appendChild(icon);
+ row.appendChild(text);
+ row.appendChild(deltaEl);
+ sfEl.appendChild(row);
+ }
+
+ if (!hasIssues) {
+ const ok = el('div', 'deck-shortfall-row');
+ ok.appendChild(el('span', 'deck-shortfall-icon', '✓'));
+ ok.appendChild(el('span', 'deck-shortfall-text deck-text-success', 'Ratios look good!'));
+ sfEl.appendChild(ok);
+ }
+
+ fixActions.style.display = hasIssues ? '' : 'none';
+ },
+
+ /* ── Fix My Ratios ────────────────────────────────────── */
+
+ _bindRightColumn() {
+ qs('#deck-autosuggest-btn').addEventListener('click', () => this._autoSuggestFromCollection());
+ qs('#deck-edhrec-refresh-btn').addEventListener('click', () => this._loadEdhRecs());
+ qs('#deck-edhrec-owned-only').addEventListener('change', (e) => {
+ this.edhRecOwnedOnly = e.target.checked;
+ this._loadEdhRecs();
+ });
+ qs('#deck-edhrec-bulk-owned-btn').addEventListener('click', () => this._bulkAddEdhRecOwned());
+ },
+
+ async _autoSuggestFromCollection() {
+ if (!this.deckId) {
+ ToastManager.show('Load a deck first.', 'warning');
+ return;
+ }
+ const suggestEl = qs('#deck-fix-suggestions');
+ suggestEl.style.display = 'block';
+ suggestEl.innerHTML = '';
+
+ try {
+ const recsResp = await apiGet(`/api/decks/${this.deckId}/recommended-from-collection?max_results=20`);
+ // API returns {grouped: {type: [cards]}, total, shortfall_types}
+ // Flatten grouped into array for rendering
+ let recsList = [];
+ if (recsResp && recsResp.grouped) {
+ for (const cards of Object.values(recsResp.grouped)) {
+ recsList.push(...cards);
+ }
+ } else if (Array.isArray(recsResp)) {
+ recsList = recsResp;
+ }
+ this._renderFixSuggestions(recsList);
+ } catch (err) {
+ suggestEl.innerHTML = `${err.message}
`;
+ }
+ },
+
+ _renderFixSuggestions(recs) {
+ const suggestEl = qs('#deck-fix-suggestions');
+ suggestEl.innerHTML = '';
+
+ if (!recs || recs.length === 0) {
+ suggestEl.appendChild(el('div', 'deck-ratio-placeholder', 'No suggestions found in collection'));
+ return;
+ }
+
+ // Group by type
+ const groups = {};
+ for (const r of recs) {
+ const type = getCardTypeGroup(r.type_line || '');
+ if (!groups[type]) groups[type] = [];
+ groups[type].push(r);
+ }
+
+ for (const [type, cards] of Object.entries(groups)) {
+ const group = el('div', 'deck-fix-group');
+
+ const titleRow = el('div', 'deck-fix-group-title');
+ titleRow.appendChild(el('span', null, type));
+ const addAllBtn = el('button', 'deck-btn deck-btn-xs deck-btn-ghost', `Add all (${cards.length})`);
+ addAllBtn.addEventListener('click', () => this._bulkAddSuggestions(cards));
+ titleRow.appendChild(addAllBtn);
+ group.appendChild(titleRow);
+
+ for (const card of cards) {
+ const row = el('div', 'deck-fix-card');
+
+ const cardName = card.card_name || card.name || '';
+ const nameEl = el('span', 'deck-fix-card-name', cardName);
+ CardPreview.attach(nameEl, card.scryfall_id);
+
+ const ownedEl = el('span', 'deck-fix-card-owned', `×${card.owned_qty || card.owned_quantity || 0}`);
+
+ const addBtn = el('button', 'deck-btn deck-btn-xs deck-btn-accent', '+');
+ addBtn.addEventListener('click', () => this.addCardToDeck(card.scryfall_id, cardName));
+
+ row.appendChild(nameEl);
+ row.appendChild(ownedEl);
+ row.appendChild(addBtn);
+ group.appendChild(row);
+ }
+
+ suggestEl.appendChild(group);
+ }
+ },
+
+ async _bulkAddSuggestions(cards) {
+ if (!this.deckId || cards.length === 0) return;
+
+ const confirmed = await ConfirmDialog.show({
+ title: 'Bulk Add Suggestions',
+ message: `Add ${cards.length} suggested card(s) to the deck?`,
+ changes: [{ label: 'Cards to add', before: 0, after: cards.length }],
+ });
+ if (!confirmed) return;
+
+ let added = 0, skipped = 0;
+ for (const card of cards) {
+ try {
+ await apiPost(`/api/decks/${this.deckId}/cards`, {
+ scryfall_id: card.scryfall_id,
+ card_name: card.card_name || card.name || '',
+ quantity: 1,
+ });
+ added++;
+ } catch (_) {
+ skipped++;
+ }
+ }
+
+ const _cr2 = await apiGet(`/api/decks/${this.deckId}/cards`);
+ this.deckCards = Array.isArray(_cr2) ? _cr2 : (_cr2.cards || []);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ ToastManager.show(`Added ${added} cards${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success');
+ },
+
+ /* ── EDHREC Recommendations ──────────────────────────── */
+
+ async _loadEdhRecs() {
+ if (!this.deckId) return;
+ const listEl = qs('#deck-edhrec-list');
+ listEl.innerHTML = '';
+
+ try {
+ const url = `/api/decks/${this.deckId}/edh-recs?only_owned=${this.edhRecOwnedOnly}&max_results=30`;
+ const edhResp = await apiGet(url);
+ this.edhRecData = Array.isArray(edhResp) ? edhResp : (edhResp.recommendations || []);
+ this._renderEdhRecs();
+ } catch (err) {
+ listEl.innerHTML = `${err.message}
`;
+ }
+ },
+
+ _renderEdhRecs() {
+ const listEl = qs('#deck-edhrec-list');
+ listEl.innerHTML = '';
+ const bulkEl = qs('#deck-edhrec-bulk');
+
+ if (!this.edhRecData || this.edhRecData.length === 0) {
+ listEl.appendChild(el('div', 'deck-ratio-placeholder', 'No EDHREC recommendations available'));
+ bulkEl.style.display = 'none';
+ return;
+ }
+
+ const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id));
+ let hasOwned = false;
+
+ for (const card of this.edhRecData) {
+ const inDeck = inDeckIds.has(card.scryfall_id);
+ if (card.owned) hasOwned = true;
+
+ const row = el('div', `deck-edhrec-card${inDeck ? ' in-deck' : ''}`);
+ row.dataset.scryfallId = card.scryfall_id;
+
+ const main = el('div', 'deck-edhrec-card-main');
+
+ const nameEl = el('span', 'deck-edhrec-card-name', card.name);
+ CardPreview.attach(nameEl, card.scryfall_id);
+
+ const meta = el('div', 'deck-edhrec-card-meta');
+
+ const pct = el('span', 'deck-edhrec-pct');
+ pct.innerHTML = `${Math.round((card.inclusion_pct || 0) * 100)}% incl.`;
+
+ const syn = el('span', `deck-edhrec-synergy${(card.synergy_score || 0) > 0 ? ' positive' : ''}`);
+ syn.textContent = `syn: ${card.synergy_score != null ? (card.synergy_score > 0 ? '+' : '') + card.synergy_score.toFixed(2) : 'n/a'}`;
+
+ const typeBadge = buildTypeBadge(card.type_line || '');
+
+ meta.appendChild(pct);
+ meta.appendChild(syn);
+ meta.appendChild(typeBadge);
+
+ if (card.owned) {
+ const ownedBadge = el('span', 'deck-owned-badge', 'Owned');
+ meta.appendChild(ownedBadge);
+ }
+
+ main.appendChild(nameEl);
+ main.appendChild(meta);
+ row.appendChild(main);
+
+ if (!inDeck) {
+ const addBtn = el('button', 'deck-btn deck-btn-xs deck-btn-accent', '+');
+ addBtn.addEventListener('click', () => this.addCardToDeck(card.scryfall_id, card.name));
+ row.appendChild(addBtn);
+ } else {
+ const inDeckLbl = el('span', 'deck-text-muted', '✓');
+ inDeckLbl.style.fontSize = '11px';
+ row.appendChild(inDeckLbl);
+ }
+
+ listEl.appendChild(row);
+ }
+
+ bulkEl.style.display = hasOwned ? '' : 'none';
+ },
+
+ async _bulkAddEdhRecOwned() {
+ if (!this.deckId) return;
+ const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id));
+ const toAdd = this.edhRecData.filter(c => c.owned && !inDeckIds.has(c.scryfall_id));
+
+ if (toAdd.length === 0) {
+ ToastManager.show('No new owned cards to add.', 'warning');
+ return;
+ }
+
+ const confirmed = await ConfirmDialog.show({
+ title: 'Add All Owned EDHREC Recs',
+ message: `Add ${toAdd.length} owned recommendation(s) to the deck?`,
+ changes: [{ label: 'Cards to add', before: 0, after: toAdd.length }],
+ });
+ if (!confirmed) return;
+
+ try {
+ await apiPost(`/api/decks/${this.deckId}/bulk-add-recommended`, {
+ source: 'edhrec',
+ only_owned: true,
+ respect_ratios: true,
+ });
+ const _cr3 = await apiGet(`/api/decks/${this.deckId}/cards`);
+ this.deckCards = Array.isArray(_cr3) ? _cr3 : (_cr3.cards || []);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ this._renderEdhRecs();
+ ToastManager.show(`Added owned EDHREC recommendations`, 'success');
+ } catch (err) {
+ // Fallback: add one by one
+ let added = 0, skipped = 0;
+ for (const card of toAdd) {
+ try {
+ await apiPost(`/api/decks/${this.deckId}/cards`, {
+ scryfall_id: card.scryfall_id,
+ card_name: card.name || '',
+ quantity: 1,
+ });
+ added++;
+ } catch (_) {
+ skipped++;
+ }
+ }
+ const _cr4 = await apiGet(`/api/decks/${this.deckId}/cards`);
+ this.deckCards = Array.isArray(_cr4) ? _cr4 : (_cr4.cards || []);
+ this._renderCardGroups();
+ this._loadAnalysis();
+ this._renderEdhRecs();
+ ToastManager.show(`Added ${added}${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success');
+ }
+ },
+
+ /* ── Collapsible Panels ───────────────────────────────── */
+
+ _bindCollapsible() {
+ qsa('.deck-panel-header-collapsible').forEach(header => {
+ const targetId = header.dataset.target;
+ const body = qs(`#${targetId}`);
+ const arrow = qs('.deck-collapse-arrow', header);
+ if (!body) return;
+ body.style.maxHeight = body.scrollHeight + 'px';
+
+ header.addEventListener('click', () => {
+ const isCollapsed = body.classList.toggle('collapsed');
+ arrow.classList.toggle('collapsed', isCollapsed);
+ body.style.maxHeight = isCollapsed ? '0px' : body.scrollHeight + 1000 + 'px';
+ });
+ });
+ },
+
+ /* ── New Deck Modal ───────────────────────────────────── */
+
+ _bindNewDeckModal() {
+ qs('#deck-new-modal-close').addEventListener('click', () => this._closeNewDeckModal());
+ qs('#deck-new-cancel').addEventListener('click', () => this._closeNewDeckModal());
+ qs('#deck-new-modal').addEventListener('click', (e) => {
+ if (e.target === qs('#deck-new-modal')) this._closeNewDeckModal();
+ });
+ qs('#deck-new-create').addEventListener('click', () => this._createNewDeck());
+
+ qs('#deck-clear-commander-btn').addEventListener('click', () => {
+ this.newCommanderScryfallId = null;
+ this.newCommanderName = '';
+ qs('#deck-new-commander-selected').style.display = 'none';
+ qs('#deck-new-commander-search').value = '';
+ });
+
+ // Commander autocomplete in new deck modal
+ const cmdSearch = qs('#deck-new-commander-search');
+ const cmdDropdown = qs('#deck-new-commander-dropdown');
+ let _cmdSuggestions = [];
+ let _cmdActiveIndex = -1;
+
+ const debouncedCmdSearch = debounce(async (q) => {
+ if (!q || q.length < 2) { cmdDropdown.style.display = 'none'; return; }
+ // Search Scryfall for legendary creatures
+ const cards = await scryfallSearchCards(`${q} is:commander`);
+ _cmdSuggestions = cards.slice(0, 8);
+ _cmdActiveIndex = -1;
+ this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown);
+ }, 350);
+
+ cmdSearch.addEventListener('input', (e) => debouncedCmdSearch(e.target.value.trim()));
+
+ cmdSearch.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowDown') {
+ _cmdActiveIndex = Math.min(_cmdActiveIndex + 1, _cmdSuggestions.length - 1);
+ this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown);
+ e.preventDefault();
+ } else if (e.key === 'ArrowUp') {
+ _cmdActiveIndex = Math.max(_cmdActiveIndex - 1, -1);
+ this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown);
+ e.preventDefault();
+ } else if (e.key === 'Enter' && _cmdActiveIndex >= 0) {
+ e.preventDefault();
+ const card = _cmdSuggestions[_cmdActiveIndex];
+ this._selectCommanderOption(card, cmdSearch, cmdDropdown);
+ } else if (e.key === 'Escape') {
+ cmdDropdown.style.display = 'none';
+ }
+ });
+
+ cmdDropdown.addEventListener('click', (e) => {
+ const item = e.target.closest('.deck-commander-option');
+ if (!item) return;
+ const idx = parseInt(item.dataset.index, 10);
+ const card = _cmdSuggestions[idx];
+ if (card) this._selectCommanderOption(card, cmdSearch, cmdDropdown);
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!cmdSearch.contains(e.target) && !cmdDropdown.contains(e.target)) {
+ cmdDropdown.style.display = 'none';
+ }
+ });
+ },
+
+ _renderCommanderDropdown(cards, activeIndex, dropdown) {
+ dropdown.innerHTML = '';
+ if (cards.length === 0) { dropdown.style.display = 'none'; return; }
+ for (let i = 0; i < cards.length; i++) {
+ const card = cards[i];
+ const item = el('div', `deck-commander-option${i === activeIndex ? ' active' : ''}`);
+ item.dataset.index = i;
+
+ const nameEl = el('span', 'deck-commander-option-name', card.name);
+ const colorWrap = el('span', 'deck-commander-option-color');
+ const ci = card.color_identity || [];
+ for (const c of ci) {
+ colorWrap.appendChild(buildColorChip(c));
+ }
+
+ item.appendChild(nameEl);
+ item.appendChild(colorWrap);
+ dropdown.appendChild(item);
+ }
+ dropdown.style.display = 'block';
+ },
+
+ _selectCommanderOption(card, input, dropdown) {
+ this.newCommanderScryfallId = card.id;
+ this.newCommanderName = card.name;
+ input.value = '';
+ dropdown.style.display = 'none';
+
+ qs('#deck-new-commander-name').textContent = card.name;
+ qs('#deck-new-commander-selected').style.display = 'flex';
+ },
+
+ _openNewDeckModal(commanderOnly = false) {
+ const modal = qs('#deck-new-modal');
+ this.newCommanderScryfallId = null;
+ this.newCommanderName = '';
+ qs('#deck-new-name').value = commanderOnly ? (this.deckName || '') : '';
+ qs('#deck-new-commander-search').value = '';
+ qs('#deck-new-commander-selected').style.display = 'none';
+ modal.style.display = 'flex';
+ if (!commanderOnly) qs('#deck-new-name').focus();
+ else qs('#deck-new-commander-search').focus();
+ },
+
+ _closeNewDeckModal() {
+ qs('#deck-new-modal').style.display = 'none';
+ this.newCommanderScryfallId = null;
+ this.newCommanderName = '';
+ },
+
+ async _createNewDeck() {
+ const name = qs('#deck-new-name').value.trim();
+ if (!name) {
+ qs('#deck-new-name').focus();
+ ToastManager.show('Please enter a deck name.', 'warning');
+ return;
+ }
+
+ const payload = {
+ name,
+ commander_scryfall_id: this.newCommanderScryfallId || undefined,
+ commander_name: this.newCommanderName || undefined,
+ };
+
+ try {
+ const deck = await apiPost('/api/decks', payload);
+ this.deckId = deck.id;
+ this.deckName = deck.name;
+ this._closeNewDeckModal();
+
+ // Add commander card BEFORE loading deck so it renders correctly
+ if (this.newCommanderScryfallId) {
+ try {
+ await apiPost(`/api/decks/${deck.id}/cards`, {
+ scryfall_id: this.newCommanderScryfallId,
+ card_name: this.newCommanderName || '',
+ quantity: 1,
+ is_commander: 1,
+ });
+ } catch (cmdErr) {
+ console.warn('Failed to add commander card:', cmdErr);
+ }
+ }
+
+ await this.loadDecks();
+ await this.loadDeck(deck.id);
+ ToastManager.show(`Created deck "${deck.name}"`, 'success');
+ } catch (err) {
+ ToastManager.show(`Failed to create deck: ${err.message}`, 'error');
+ }
+ },
+
+ /* ── Import ───────────────────────────────────────────── */
+
+ _importFileText: '',
+
+ _initImportModal() {
+ const modal = qs('#deck-import-modal');
+ const closeModal = () => { modal.style.display = 'none'; };
+ qs('#deck-import-modal-close').addEventListener('click', closeModal);
+ qs('#deck-import-cancel').addEventListener('click', closeModal);
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
+
+ // Tabs
+ for (const tab of document.querySelectorAll('.deck-import-tab')) {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.deck-import-tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ const which = tab.dataset.tab;
+ qs('#deck-import-paste-panel').style.display = which === 'paste' ? '' : 'none';
+ qs('#deck-import-file-panel').style.display = which === 'file' ? '' : 'none';
+ });
+ }
+
+ // File upload
+ const dropzone = qs('#deck-import-dropzone');
+ const fileInput = qs('#deck-import-file');
+ dropzone.addEventListener('click', () => fileInput.click());
+ dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
+ dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
+ dropzone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropzone.classList.remove('dragover');
+ if (e.dataTransfer.files.length) this._handleImportFile(e.dataTransfer.files[0]);
+ });
+ fileInput.addEventListener('change', () => {
+ if (fileInput.files.length) this._handleImportFile(fileInput.files[0]);
+ });
+
+ // Mode radio toggles name row visibility
+ for (const radio of document.querySelectorAll('input[name="import-mode"]')) {
+ radio.addEventListener('change', () => {
+ const mode = document.querySelector('input[name="import-mode"]:checked').value;
+ qs('#deck-import-new-name-row').style.display = mode === 'new' ? '' : 'none';
+ });
+ }
+
+ // Go button
+ qs('#deck-import-go').addEventListener('click', () => this._doImport());
+ },
+
+ _handleImportFile(file) {
+ const reader = new FileReader();
+ reader.onload = () => {
+ this._importFileText = reader.result;
+ qs('#deck-import-file-name').textContent = file.name + ' (' + (reader.result.split('\n').length) + ' lines)';
+ };
+ reader.readAsText(file);
+ },
+
+ _openImportModal() {
+ const modal = qs('#deck-import-modal');
+ qs('#deck-import-textarea').value = '';
+ qs('#deck-import-name').value = '';
+ qs('#deck-import-file-name').textContent = '';
+ qs('#deck-import-file').value = '';
+ qs('#deck-import-progress').style.display = 'none';
+ qs('#deck-import-result').style.display = 'none';
+ qs('#deck-import-go').disabled = false;
+ qs('#deck-import-go').textContent = 'Import';
+ this._importFileText = '';
+
+ // If no deck loaded, default to "new"
+ if (!this.deckId) {
+ document.querySelector('input[name="import-mode"][value="new"]').checked = true;
+ qs('#deck-import-new-name-row').style.display = '';
+ }
+
+ modal.style.display = 'flex';
+ qs('#deck-import-textarea').focus();
+ },
+
+ async _doImport() {
+ const activeTab = document.querySelector('.deck-import-tab.active').dataset.tab;
+ let text = '';
+
+ if (activeTab === 'paste') {
+ text = qs('#deck-import-textarea').value.trim();
+ } else {
+ text = this._importFileText;
+ }
+
+ if (!text) {
+ ToastManager.show('No decklist to import.', 'warning');
+ return;
+ }
+
+ const mode = document.querySelector('input[name="import-mode"]:checked').value;
+ const importBtn = qs('#deck-import-go');
+ importBtn.disabled = true;
+ importBtn.textContent = 'Importing...';
+
+ qs('#deck-import-progress').style.display = 'block';
+ qs('#deck-import-progress-bar').style.width = '30%';
+ qs('#deck-import-result').style.display = 'none';
+
+ try {
+ let result;
+
+ if (mode === 'new') {
+ const name = qs('#deck-import-name').value.trim();
+ const res = await fetch(LAB_API_BASE + '/api/decks/import-new', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, name }),
+ });
+ result = await res.json();
+ if (result.error) throw new Error(result.error);
+
+ qs('#deck-import-progress-bar').style.width = '100%';
+ this._showImportResult(result);
+
+ // Load the new deck
+ await this.loadDecks();
+ await this.loadDeck(result.deckId);
+ ToastManager.show('Imported "' + result.deckName + '" (' + result.added + ' cards)', 'success');
+
+ } else {
+ // add or replace into current deck
+ if (!this.deckId) {
+ ToastManager.show('Select or create a deck first, or use "Create new deck" mode.', 'warning');
+ importBtn.disabled = false;
+ importBtn.textContent = 'Import';
+ qs('#deck-import-progress').style.display = 'none';
+ return;
+ }
+
+ const clearFirst = mode === 'replace';
+ const res = await fetch(LAB_API_BASE + '/api/decks/' + this.deckId + '/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, clearFirst }),
+ });
+ result = await res.json();
+ if (result.error) throw new Error(result.error);
+
+ qs('#deck-import-progress-bar').style.width = '100%';
+ this._showImportResult(result);
+
+ // Reload current deck
+ await this.loadDeck(this.deckId);
+ const msg = 'Imported ' + result.added + ' cards' + (result.failed > 0 ? ' (' + result.failed + ' not found)' : '');
+ ToastManager.show(msg, result.failed > 0 ? 'warning' : 'success');
+ }
+
+ // Close modal after short delay
+ setTimeout(() => {
+ qs('#deck-import-modal').style.display = 'none';
+ }, 1500);
+
+ } catch (err) {
+ ToastManager.show('Import failed: ' + err.message, 'error');
+ } finally {
+ importBtn.disabled = false;
+ importBtn.textContent = 'Import';
+ }
+ },
+
+ _showImportResult(result) {
+ const el = qs('#deck-import-result');
+ el.style.display = 'block';
+
+ let html = '';
+ html += '
' + result.added + ' cards imported ';
+ if (result.failed > 0) {
+ html += ' —
' + result.failed + ' not found ';
+ html += '
';
+ html += 'Not found: ' + (result.failedNames || []).join(', ');
+ html += '
';
+ }
+ html += '
';
+ el.innerHTML = html;
+ },
+
+ /* ── Export ───────────────────────────────────────────── */
+
+ _exportDck() {
+ if (!this.deckId || this.deckCards.length === 0) {
+ ToastManager.show('No deck loaded.', 'warning');
+ return;
+ }
+ const lines = [];
+ const commanders = this.deckCards.filter(c => c.is_commander);
+ const nonCmd = this.deckCards.filter(c => !c.is_commander);
+
+ if (commanders.length > 0) {
+ lines.push('[Commander]');
+ for (const c of commanders) {
+ lines.push(`1 ${c.card_name}`);
+ }
+ lines.push('');
+ }
+
+ lines.push('[Deck]');
+ for (const c of nonCmd) {
+ lines.push(`${c.quantity || 1} ${c.card_name}`);
+ }
+
+ const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${(this.deckName || 'deck').replace(/[^a-z0-9_-]/gi, '_')}.dck`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ ToastManager.show('Exported .dck file', 'success');
+ },
+
+ _exportToLanClient() {
+ if (!this.deckId || this.deckCards.length === 0) {
+ ToastManager.show('No deck loaded.', 'warning');
+ return;
+ }
+ const commanders = this.deckCards.filter(c => c.is_commander);
+ const deck = this.deckCards.map(c => ({
+ name: c.card_name,
+ quantity: c.quantity || 1,
+ is_commander: !!c.is_commander,
+ }));
+ window.parent.postMessage({
+ type: 'DECK_BUILDER_EXPORT',
+ deck: deck,
+ deckName: this.deckName || 'Untitled',
+ commander: commanders.length > 0 ? commanders[0].card_name : '',
+ }, '*');
+ ToastManager.show('Deck sent to game setup!', 'success');
+ },
+
+ async _exportToSim() {
+ if (!this.deckId) {
+ ToastManager.show('Load a deck first.', 'warning');
+ return;
+ }
+ const btn = qs('#deck-sim-btn');
+ btn.disabled = true;
+ const originalText = btn.innerHTML;
+ btn.innerHTML = '
Simulating...';
+
+ try {
+ // Run a quick 10-game sim directly
+ const res = await fetch(LAB_API_BASE + '/api/sim/run-from-deck', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ deckId: this.deckId, numGames: 10, recordLogs: false }),
+ });
+ const ct = res.headers.get('content-type') || '';
+ if (!ct.includes('application/json')) {
+ const txt = await res.text();
+ throw new Error('Server returned non-JSON (HTTP ' + res.status + '): ' + txt.substring(0, 120));
+ }
+ const startData = await res.json();
+ if (startData.error) throw new Error(startData.error);
+
+ // Poll until done
+ const simId = startData.simId;
+ let done = false;
+ while (!done) {
+ await new Promise(r => setTimeout(r, 300));
+ const statusRes = await fetch(LAB_API_BASE + '/api/sim/status?simId=' + simId);
+ if (!statusRes.ok) throw new Error('Status poll failed: HTTP ' + statusRes.status);
+ const status = await statusRes.json();
+ btn.innerHTML = '
' + status.completed + '/' + status.total;
+ if (status.status === 'complete') {
+ done = true;
+ const resultRes = await fetch(LAB_API_BASE + '/api/sim/result?simId=' + simId);
+ if (!resultRes.ok) throw new Error('Result fetch failed: HTTP ' + resultRes.status);
+ const result = await resultRes.json();
+ const s = result.summary;
+ ToastManager.show(
+ `Win Rate: ${s.winRate}% (${s.wins}W/${s.losses}L) | Avg ${s.avgTurns} turns | ${s.elapsedSeconds}s`,
+ s.winRate >= 50 ? 'success' : 'warning',
+ 6000
+ );
+ // Offer to open full simulator
+ if (confirm(`Quick sim: ${s.winRate}% win rate (${s.wins}/${s.totalGames}).\n\nOpen the full Simulator page for detailed results?`)) {
+ window.location.href = 'simulator.html';
+ }
+ } else if (status.status === 'error') {
+ throw new Error(status.error || 'Simulation failed');
+ }
+ }
+ } catch (err) {
+ ToastManager.show(`Simulation failed: ${err.message}`, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = originalText;
+ }
+ },
+
+ /* ── AI Panels (Perplexity) ──────────────────────────── */
+
+
+ /* ── Export Deck to Forge Sim Folder ────────────────── */
+
+ async _exportDckToSim() {
+ if (!this.deckId) {
+ ToastManager.show('Load a deck first.', 'warning');
+ return;
+ }
+ const btn = qs('#deck-export-sim-btn');
+ btn.disabled = true;
+ const originalText = btn.innerHTML;
+ btn.innerHTML = '
Exporting...';
+
+ try {
+ const res = await fetch(LAB_API_BASE + '/api/decks/' + this.deckId + '/export-to-sim', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ const ct = res.headers.get('content-type') || '';
+ if (!ct.includes('application/json')) {
+ const txt = await res.text();
+ throw new Error('Server returned non-JSON (HTTP ' + res.status + '): ' + txt.substring(0, 120));
+ }
+ const data = await res.json();
+ if (!data.success) throw new Error(data.detail || 'Export failed');
+
+ ToastManager.show(
+ 'Exported "' + data.deckName + '" (' + data.totalCards + ' cards) to Forge sim folder',
+ 'success',
+ 5000
+ );
+ } catch (err) {
+ ToastManager.show('Export failed: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = originalText;
+ }
+ },
+
+ async _checkPplxStatus() {
+ try {
+ const status = await apiGet('/api/pplx/status');
+ this.pplxConfigured = status && status.configured;
+ } catch {
+ this.pplxConfigured = false;
+ }
+ const researchPanel = qs('#deck-ai-research-panel');
+ const generatePanel = qs('#deck-ai-generate-panel');
+ if (researchPanel) researchPanel.style.display = this.pplxConfigured ? '' : 'none';
+ if (generatePanel) generatePanel.style.display = this.pplxConfigured ? '' : 'none';
+ },
+
+ _bindAIPanels() {
+ // Research button
+ const researchBtn = qs('#ai-research-btn');
+ if (researchBtn) {
+ researchBtn.addEventListener('click', () => this._runAIResearch());
+ }
+
+ // Generate button
+ const genBtn = qs('#ai-gen-btn');
+ if (genBtn) {
+ genBtn.addEventListener('click', () => this._runAIGenerate());
+ }
+
+ // Commander autocomplete for AI Generate
+ this._bindAIGenCommanderSearch();
+ },
+
+ /** Update the research button state — call after deck changes */
+ _updateAIResearchBtn() {
+ const btn = qs('#ai-research-btn');
+ if (!btn) return;
+ btn.disabled = !this.deckId || !this.pplxConfigured;
+ },
+
+ /* ── AI Research Flow ─────────────────────────────────── */
+
+ async _runAIResearch() {
+ if (!this.deckId) {
+ ToastManager.show('Load a deck first.', 'warning');
+ return;
+ }
+ const btn = qs('#ai-research-btn');
+ const loading = qs('#ai-research-loading');
+ const results = qs('#ai-research-results');
+ const controls = qs('#deck-ai-research-body .deck-ai-controls');
+
+ const goal = qs('#ai-research-goal').value.trim() || undefined;
+ const budgetRaw = qs('#ai-research-budget').value;
+ const budget = budgetRaw ? parseFloat(budgetRaw) : undefined;
+ const omitRaw = qs('#ai-research-omit').value.trim();
+ const omitCards = omitRaw ? omitRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
+ const useCollection = qs('#ai-research-use-collection').checked;
+
+ btn.disabled = true;
+ loading.style.display = 'flex';
+ results.style.display = 'none';
+ results.innerHTML = '';
+
+ try {
+ const data = await apiPost('/api/deck-research', {
+ deck_id: this.deckId,
+ goal,
+ budget_usd: budget,
+ omit_cards: omitCards,
+ use_collection: useCollection,
+ });
+
+ if (data.error) {
+ results.innerHTML = 'AI returned an error: ' + this._esc(data.error) + '
';
+ results.style.display = 'block';
+ return;
+ }
+
+ this._renderResearchResults(data, results);
+ results.style.display = 'block';
+
+ // Expand the panel body max-height so results are visible
+ const body = qs('#deck-ai-research-body');
+ if (body) body.style.maxHeight = body.scrollHeight + 2000 + 'px';
+ } catch (err) {
+ results.innerHTML = 'Research failed: ' + this._esc(err.message) + '
';
+ results.style.display = 'block';
+ ToastManager.show('AI research failed: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ loading.style.display = 'none';
+ }
+ },
+
+ _renderResearchResults(data, container) {
+ let html = '';
+ const esc = (t) => this._esc(t);
+
+ // ── Header: Rating + Bracket + Archetype ──────────────────
+ const ratingNum = parseInt(data.overall_rating, 10);
+ const ratingClass = isNaN(ratingNum) ? '' : (ratingNum >= 7 ? 'high' : ratingNum >= 4 ? 'mid' : 'low');
+ const bracketLvl = data.bracket_level && data.bracket_level.level;
+ const bracketCls = bracketLvl <= 1 ? 'bracket-1' : bracketLvl <= 2 ? 'bracket-2' : bracketLvl <= 3 ? 'bracket-3' : 'bracket-4';
+
+ html += '';
+
+ // Rating explanation
+ if (data.rating_explanation) {
+ html += '' + esc(data.rating_explanation) + '
';
+ }
+
+ // ── Deck Description ──────────────────────────────────────
+ if (data.deck_description) {
+ html += '';
+ html += '
Deck Overview
';
+ html += '
' + esc(data.deck_description) + '
';
+ html += '
';
+ }
+
+ // ── Bracket Details ───────────────────────────────────────
+ if (data.bracket_level) {
+ const b = data.bracket_level;
+ html += '';
+ html += '
Bracket Assessment
';
+ html += '
';
+ if (b.reasoning) html += '
Why Bracket ' + (b.level || '?') + ': ' + esc(b.reasoning) + '
';
+ if (b.power_ceiling) html += '
Power Ceiling: ' + esc(b.power_ceiling) + '
';
+ html += '
';
+ }
+
+ // ── Win Conditions ────────────────────────────────────────
+ if (data.win_conditions && data.win_conditions.length) {
+ html += '';
+ html += '
Win Conditions
';
+ for (const wc of data.win_conditions) {
+ const relCls = wc.reliability === 'high' ? 'high' : wc.reliability === 'medium' ? 'mid' : 'low';
+ html += '
';
+ html += '';
+ if (wc.cards_involved && wc.cards_involved.length) {
+ html += '
';
+ for (const cn of wc.cards_involved) html += '' + esc(cn) + ' ';
+ html += '
';
+ }
+ if (wc.description) html += '
' + esc(wc.description) + '
';
+ html += '
';
+ }
+ html += '
';
+ }
+
+ // ── Synergy Packages ──────────────────────────────────────
+ if (data.synergy_packages && data.synergy_packages.length) {
+ html += '';
+ html += '
Synergy Packages
';
+ for (const sp of data.synergy_packages) {
+ const strCls = sp.strength === 'strong' ? 'high' : sp.strength === 'moderate' ? 'mid' : 'low';
+ html += '
';
+ html += '';
+ if (sp.cards && sp.cards.length) {
+ html += '
';
+ for (const cn of sp.cards) html += '' + esc(cn) + ' ';
+ html += '
';
+ }
+ if (sp.description) html += '
' + esc(sp.description) + '
';
+ html += '
';
+ }
+ html += '
';
+ }
+
+ // ── Threat Assessment (game phases) ───────────────────────
+ if (data.threat_assessment) {
+ const ta = data.threat_assessment;
+ html += '';
+ html += '
Threat Assessment
';
+ html += '
';
+ if (ta.early_game) html += '
Early (T1-3) ' + esc(ta.early_game) + '
';
+ if (ta.mid_game) html += '
Mid (T4-7) ' + esc(ta.mid_game) + '
';
+ if (ta.late_game) html += '
Late (T8+) ' + esc(ta.late_game) + '
';
+ if (ta.vulnerability) html += '
Vulnerable To ' + esc(ta.vulnerability) + '
';
+ html += '
';
+ }
+
+ // ── Strengths / Weaknesses ────────────────────────────────
+ if ((data.strengths && data.strengths.length) || (data.weaknesses && data.weaknesses.length)) {
+ html += '';
+ if (data.strengths && data.strengths.length) {
+ html += '
';
+ html += '
Strengths
';
+ html += '
';
+ for (const s of data.strengths) html += '' + esc(s) + ' ';
+ html += ' ';
+ }
+ if (data.weaknesses && data.weaknesses.length) {
+ html += '
';
+ html += '
Weaknesses
';
+ html += '
';
+ for (const w of data.weaknesses) html += '' + esc(w) + ' ';
+ html += ' ';
+ }
+ html += '
';
+ }
+
+ // ── Role Gaps ────────────────────────────────────────────
+ if (data.role_gaps && typeof data.role_gaps === 'object') {
+ html += '';
+ html += '
Role Analysis
';
+ html += '
';
+ for (const [role, info] of Object.entries(data.role_gaps)) {
+ if (!info || typeof info !== 'object') continue;
+ const cur = info.current || 0;
+ const rec = info.recommended || 0;
+ const diff = cur - rec;
+ const barPct = rec > 0 ? Math.min(100, Math.round((cur / rec) * 100)) : 100;
+ const barCls = diff >= 0 ? 'good' : (diff >= -2 ? 'warn' : 'bad');
+ html += '
';
+ html += '
' + esc(role.replace('_', ' ')) + ' ';
+ html += '
';
+ html += '
' + cur + '/' + rec + ' ';
+ html += '
';
+ if (info.note) html += '
' + esc(info.note) + '
';
+ }
+ html += '
';
+ }
+
+ // ── Mana Analysis ────────────────────────────────────────
+ if (data.mana_analysis && typeof data.mana_analysis === 'object') {
+ const ma = data.mana_analysis;
+ html += '';
+ html += '
Mana Base Analysis
';
+ if (ma.land_count) html += '
Lands: ' + esc(ma.land_count) + '
';
+ if (ma.color_fixing) html += '
Color Fixing: ' + esc(ma.color_fixing) + '
';
+ if (ma.ramp_package) html += '
Ramp: ' + esc(ma.ramp_package) + '
';
+ if (ma.curve_assessment) html += '
Curve: ' + esc(ma.curve_assessment) + '
';
+ if (ma.problem_cards && ma.problem_cards.length) {
+ html += '
Problem Cards: ';
+ html += ma.problem_cards.map(c => '' + esc(c) + ' ').join(' ');
+ html += '
';
+ }
+ html += '
';
+ }
+
+ // ── Cuts ─────────────────────────────────────────────────
+ if (data.cuts && data.cuts.length) {
+ html += '';
+ html += '
Suggested Cuts (' + data.cuts.length + ')
';
+ for (const c of data.cuts) {
+ const sevCls = c.severity === 'must_cut' ? 'sev-must' : c.severity === 'should_cut' ? 'sev-should' : 'sev-consider';
+ html += '
';
+ html += '' + esc(c.name) + ' ';
+ if (c.severity) html += '' + esc(c.severity.replace('_', ' ')) + ' ';
+ html += '
';
+ if (c.reason) html += '
' + esc(c.reason) + '
';
+ }
+ html += '
';
+ }
+
+ // ── Adds ─────────────────────────────────────────────────
+ if (data.adds && data.adds.length) {
+ html += '';
+ html += '
Suggested Adds (' + data.adds.length + ')
';
+ for (const a of data.adds) {
+ const priCls = a.priority === 'critical' ? 'pri-critical' : a.priority === 'high' ? 'pri-high' : a.priority === 'medium' ? 'pri-medium' : 'pri-nice';
+ html += '
';
+ html += '' + esc(a.name) + ' ';
+ if (a.role) html += '' + esc(a.role.replace('_', ' ')) + ' ';
+ if (a.priority) html += '' + esc(a.priority.replace('_', ' ')) + ' ';
+ if (a.from_collection) html += 'Owned ';
+ if (a.estimated_price_usd) html += '$' + a.estimated_price_usd.toFixed(2) + ' ';
+ if (a.scryfall_id) html += '+ ';
+ html += '
';
+ // Reason + synergy line
+ let detail = '';
+ if (a.reason) detail += esc(a.reason);
+ if (a.synergy_with && a.synergy_with.length) {
+ detail += (detail ? ' ' : '') + '
Synergizes with: ' + a.synergy_with.map(c => esc(c)).join(', ') + ' ';
+ }
+ if (detail) html += '
' + detail + '
';
+ }
+ if (data.adds_total_usd) {
+ html += '
Total adds cost $' + data.adds_total_usd.toFixed(2) + '
';
+ }
+ html += '
';
+ }
+
+ // ── Strategy Notes ────────────────────────────────────────
+ if (data.strategy_notes) {
+ html += '';
+ html += '
Strategy Notes
';
+ html += '
' + esc(data.strategy_notes) + '
';
+ html += '
';
+ }
+
+ container.innerHTML = html;
+
+ // Bind add buttons
+ qsa('.deck-ai-card-add-btn', container).forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const sfid = btn.dataset.sfid;
+ const name = btn.dataset.name;
+ if (sfid) this.addCardToDeck(sfid, name);
+ });
+ });
+
+ // Attach card preview on hover for add rows with scryfall_id
+ qsa('.deck-ai-card-row[data-scryfall-id]', container).forEach(row => {
+ const sfid = row.dataset.scryfallId;
+ if (sfid) {
+ const nameEl = qs('.deck-ai-card-name', row);
+ if (nameEl) CardPreview.attach(nameEl, sfid);
+ }
+ });
+ },
+
+ /* ── AI Generate Flow ─────────────────────────────────── */
+
+ _bindAIGenCommanderSearch() {
+ const input = qs('#ai-gen-commander');
+ const dropdown = qs('#ai-gen-commander-dropdown');
+ if (!input || !dropdown) return;
+
+ const debouncedSearch = debounce(async (q) => {
+ if (!q || q.length < 2) { dropdown.style.display = 'none'; return; }
+ const cards = await scryfallSearchCards(q + ' is:commander');
+ this._aiGenSuggestions = cards.slice(0, 8);
+ this._aiGenActiveIndex = -1;
+ this._renderAIGenDropdown();
+ }, 350);
+
+ input.addEventListener('input', (e) => {
+ // Clear selection if user edits after selecting
+ this.aiGenCommanderName = '';
+ const sel = qs('#ai-gen-commander-selected');
+ if (sel) sel.style.display = 'none';
+ debouncedSearch(e.target.value.trim());
+ });
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'ArrowDown') {
+ this._aiGenActiveIndex = Math.min(this._aiGenActiveIndex + 1, this._aiGenSuggestions.length - 1);
+ this._renderAIGenDropdown();
+ e.preventDefault();
+ } else if (e.key === 'ArrowUp') {
+ this._aiGenActiveIndex = Math.max(this._aiGenActiveIndex - 1, -1);
+ this._renderAIGenDropdown();
+ e.preventDefault();
+ } else if (e.key === 'Enter' && this._aiGenActiveIndex >= 0) {
+ e.preventDefault();
+ const card = this._aiGenSuggestions[this._aiGenActiveIndex];
+ if (card) this._selectAIGenCommander(card);
+ } else if (e.key === 'Escape') {
+ dropdown.style.display = 'none';
+ }
+ });
+
+ dropdown.addEventListener('click', (e) => {
+ const item = e.target.closest('.deck-ai-commander-option');
+ if (!item) return;
+ const idx = parseInt(item.dataset.index, 10);
+ const card = this._aiGenSuggestions[idx];
+ if (card) this._selectAIGenCommander(card);
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!input.contains(e.target) && !dropdown.contains(e.target)) {
+ dropdown.style.display = 'none';
+ }
+ });
+ },
+
+ _renderAIGenDropdown() {
+ const dropdown = qs('#ai-gen-commander-dropdown');
+ dropdown.innerHTML = '';
+ if (this._aiGenSuggestions.length === 0) { dropdown.style.display = 'none'; return; }
+ for (let i = 0; i < this._aiGenSuggestions.length; i++) {
+ const card = this._aiGenSuggestions[i];
+ const item = el('div', 'deck-ai-commander-option' + (i === this._aiGenActiveIndex ? ' active' : ''));
+ item.dataset.index = i;
+
+ const nameEl = el('span', 'deck-ai-commander-option-name', card.name);
+ const colorWrap = el('span', 'deck-commander-option-color');
+ for (const c of (card.color_identity || [])) {
+ colorWrap.appendChild(buildColorChip(c));
+ }
+ item.appendChild(nameEl);
+ item.appendChild(colorWrap);
+ dropdown.appendChild(item);
+ }
+ dropdown.style.display = 'block';
+ },
+
+ _selectAIGenCommander(card) {
+ this.aiGenCommanderName = card.name;
+ const input = qs('#ai-gen-commander');
+ const dropdown = qs('#ai-gen-commander-dropdown');
+ input.value = card.name;
+ dropdown.style.display = 'none';
+ },
+
+ async _runAIGenerate() {
+ const commander = this.aiGenCommanderName || qs('#ai-gen-commander').value.trim();
+ if (!commander) {
+ ToastManager.show('Enter a commander name.', 'warning');
+ qs('#ai-gen-commander').focus();
+ return;
+ }
+
+ const btn = qs('#ai-gen-btn');
+ const loading = qs('#ai-gen-loading');
+ const results = qs('#ai-gen-results');
+ const controls = qs('#deck-ai-generate-body .deck-ai-controls');
+
+ const budgetRaw = qs('#ai-gen-budget').value;
+ const budget = budgetRaw ? parseFloat(budgetRaw) : undefined;
+ const omitRaw = qs('#ai-gen-omit').value.trim();
+ const omitCards = omitRaw ? omitRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
+ const useCollection = qs('#ai-gen-use-collection').checked;
+
+ btn.disabled = true;
+ loading.style.display = 'flex';
+ results.style.display = 'none';
+ results.innerHTML = '';
+
+ try {
+ const data = await apiFetch('/api/deck-generate', {
+ method: 'POST',
+ body: JSON.stringify({
+ commander,
+ budget_usd: budget,
+ omit_cards: omitCards,
+ use_collection: useCollection,
+ }),
+ });
+
+ if (data.error) {
+ results.innerHTML = 'AI returned an error: ' + this._esc(data.error) + '
';
+ results.style.display = 'block';
+ return;
+ }
+
+ this._lastGenResult = data;
+ this._renderGenerateResults(data, results);
+ results.style.display = 'block';
+
+ // Expand panel
+ const body = qs('#deck-ai-generate-body');
+ if (body) body.style.maxHeight = body.scrollHeight + 5000 + 'px';
+ } catch (err) {
+ results.innerHTML = 'Generation failed: ' + this._esc(err.message) + '
';
+ results.style.display = 'block';
+ ToastManager.show('AI generation failed: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ loading.style.display = 'none';
+ }
+ },
+
+ _renderGenerateResults(data, container) {
+ let html = '';
+
+ // Strategy
+ if (data.strategy) {
+ html += '' + this._esc(data.strategy) + '
';
+ }
+
+ // Stats row
+ html += '';
+ html += '
' + (data.total_cards || '?') + ' Cards
';
+ html += '
$' + (data.real_total_usd || 0).toFixed(0) + ' Total
';
+ html += '
' + (data.from_collection_count || 0) + ' Owned
';
+ html += '
';
+
+ // Cards grouped by role
+ if (data.cards && data.cards.length) {
+ const groups = {};
+ for (const c of data.cards) {
+ const role = c.role || 'other';
+ if (!groups[role]) groups[role] = [];
+ groups[role].push(c);
+ }
+
+ const roleOrder = ['commander', 'creature', 'ramp', 'draw', 'removal', 'land', 'utility', 'win_condition', 'other'];
+ const sortedRoles = Object.keys(groups).sort((a, b) => {
+ const ai = roleOrder.indexOf(a);
+ const bi = roleOrder.indexOf(b);
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
+ });
+
+ for (const role of sortedRoles) {
+ const cards = groups[role];
+ html += '';
+ html += '
' + this._esc(role.replace('_', ' ')) + ' (' + cards.length + ')
';
+ for (const c of cards) {
+ html += '
';
+ html += '' + this._esc(c.name) + ' ';
+ if (c.from_collection) html += 'Owned ';
+ if (c.estimated_price_usd) html += '$' + c.estimated_price_usd.toFixed(2) + ' ';
+ html += '
';
+ }
+ html += '
';
+ }
+ }
+
+ // Reasoning
+ if (data.reasoning) {
+ const r = data.reasoning;
+ html += '';
+ html += '
AI Reasoning
';
+ if (r.strategy) html += '
Strategy: ' + this._esc(r.strategy) + '
';
+ if (r.mana_curve) html += '
Mana Curve: ' + this._esc(r.mana_curve) + '
';
+ if (r.key_synergies) html += '
Key Synergies: ' + this._esc(r.key_synergies) + '
';
+ if (r.budget_notes) html += '
Budget: ' + this._esc(r.budget_notes) + '
';
+ if (r.collection_usage_notes) html += '
Collection Usage: ' + this._esc(r.collection_usage_notes) + '
';
+ html += '
';
+ }
+
+ // Apply button
+ html += '✅ Create Deck from AI Results ';
+
+ container.innerHTML = html;
+
+ // Bind apply button
+ const applyBtn = qs('#ai-gen-apply-btn', container);
+ if (applyBtn) {
+ applyBtn.addEventListener('click', () => this._applyGeneratedDeck());
+ }
+
+ // Attach card preview on hover
+ qsa('.deck-ai-card-row[data-scryfall-id]', container).forEach(row => {
+ const sfid = row.dataset.scryfallId;
+ if (sfid) {
+ const nameEl = qs('.deck-ai-card-name', row);
+ if (nameEl) CardPreview.attach(nameEl, sfid);
+ }
+ });
+ },
+
+ async _applyGeneratedDeck() {
+ const data = this._lastGenResult;
+ if (!data || !data.cards || !data.cards.length) {
+ ToastManager.show('No generated deck to apply.', 'warning');
+ return;
+ }
+
+ const applyBtn = qs('#ai-gen-apply-btn');
+ if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Creating deck...'; }
+
+ try {
+ // Create a new deck
+ const deckName = (data.commander || 'AI Deck') + ' (AI)';
+ const deck = await apiPost('/api/decks', { name: deckName });
+ this.deckId = deck.id;
+ this.deckName = deck.name;
+
+ // Add cards one at a time
+ let added = 0;
+ let failed = 0;
+ for (const card of data.cards) {
+ if (!card.scryfall_id && !card.name) continue;
+
+ try {
+ let sfid = card.scryfall_id;
+ // If no scryfall_id, try fuzzy Scryfall lookup
+ if (!sfid) {
+ const scryCard = await scryfallFuzzySearch(card.name);
+ if (scryCard) sfid = scryCard.id;
+ }
+ if (!sfid) { failed++; continue; }
+
+ const isCommander = (card.role === 'commander') ? 1 : 0;
+ await apiPost('/api/decks/' + deck.id + '/cards', {
+ scryfall_id: sfid,
+ card_name: card.name,
+ quantity: card.count || 1,
+ is_commander: isCommander,
+ });
+ added++;
+ } catch (cardErr) {
+ console.warn('Failed to add ' + card.name + ':', cardErr.message);
+ failed++;
+ }
+ }
+
+ // Reload
+ await this.loadDecks();
+ await this.loadDeck(deck.id);
+ qs('#deck-selector').value = deck.id;
+
+ const msg = 'Created "' + deckName + '" with ' + added + ' cards';
+ if (failed) {
+ ToastManager.show(msg + ' (' + failed + ' failed to resolve)', 'warning', 5000);
+ } else {
+ ToastManager.show(msg, 'success', 4000);
+ }
+ } catch (err) {
+ ToastManager.show('Failed to create deck: ' + err.message, 'error');
+ } finally {
+ if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = '\u2705 Create Deck from AI Results'; }
+ }
+ },
+
+ /** HTML-escape helper */
+ _esc(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ },
+};
+
+/* ── Bootstrap ─────────────────────────────────────────────── */
+
+document.addEventListener('DOMContentLoaded', () => {
+ DeckBuilder.init().catch(err => {
+ console.error('DeckBuilder init error:', err);
+ ToastManager.show('Failed to initialize deck builder', 'error');
+ });
+});
diff --git a/lan-client/deckgenerator.css b/lan-client/deckgenerator.css
new file mode 100644
index 0000000..67e884e
--- /dev/null
+++ b/lan-client/deckgenerator.css
@@ -0,0 +1,1402 @@
+/* ═══════════════════════════════════════════════════════════
+ Commander AI Lab — Auto Deck Generator Styles
+ Uses dg- prefix to avoid conflicts with lab- / deck- / coll-
+ Tokens from global.css design system
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── JS Backward-Compat Aliases ─────────────────────────
+ deckgenerator.js uses var(--lab-surface) in a template literal.
+ Map it to the global token so inline styles work.
+ ──────────────────────────────────────────────────────── */
+:root {
+ --lab-surface: var(--surface-1);
+}
+
+/* ── Z-INDEX STACK ────────────────────────────────────────
+ 10 card group headers (sticky)
+ 50 commander dropdown
+ 60 card preview
+ 500 toast container
+ ──────────────────────────────────────────────────────── */
+
+/* ── 1. Page Base ───────────────────────────────────────── */
+
+#deckgen-page {
+ min-height: calc(100vh - var(--nav-height));
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ flex-direction: column;
+ isolation: isolate;
+}
+
+/* ── 2. Main Layout ─────────────────────────────────────── */
+
+.dg-main {
+ display: grid;
+ grid-template-columns: 360px 1fr;
+ gap: 0;
+ flex: 1;
+ min-height: 0;
+}
+
+.dg-col-config {
+ border-right: 1px solid var(--border);
+ padding: var(--sp-5);
+ overflow-y: auto;
+ overflow-x: visible;
+ height: calc(100vh - var(--nav-height));
+ position: sticky;
+ top: var(--nav-height);
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-4);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.dg-col-config::-webkit-scrollbar { width: 6px; }
+.dg-col-config::-webkit-scrollbar-track { background: transparent; }
+.dg-col-config::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+.dg-col-results {
+ padding: var(--sp-4) var(--sp-5);
+ overflow-y: auto;
+ min-height: calc(100vh - var(--nav-height));
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.dg-col-results::-webkit-scrollbar { width: 6px; }
+.dg-col-results::-webkit-scrollbar-track { background: transparent; }
+.dg-col-results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+/* ── 3. Panels ──────────────────────────────────────────── */
+
+.dg-panel {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: visible;
+}
+
+/* Commander panel needs visible overflow for dropdown */
+.dg-panel.dg-panel-commander {
+ overflow: visible;
+}
+
+.dg-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--sp-3) var(--sp-4);
+ background: var(--accent-dim);
+ border-bottom: 1px solid var(--border);
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
+}
+
+.dg-panel-title {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+}
+
+.dg-panel-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.dg-panel-hint.dg-over {
+ color: var(--error);
+ font-weight: 600;
+}
+
+.dg-panel-body {
+ padding: var(--sp-4);
+}
+
+/* ── 4. Input ───────────────────────────────────────────── */
+
+.dg-input {
+ width: 100%;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: var(--sp-2) var(--sp-3);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
+}
+
+.dg-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-glow);
+}
+
+.dg-input::placeholder {
+ color: var(--text-secondary);
+}
+
+/* ── 5. Commander Search ────────────────────────────────── */
+
+.dg-commander-search-wrap {
+ position: relative;
+}
+
+.dg-commander-dropdown {
+ display: none;
+ position: fixed;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ max-height: 280px;
+ overflow-y: auto;
+ z-index: 9999;
+ box-shadow: var(--shadow-lg);
+}
+
+.dg-commander-dropdown.open {
+ display: block;
+}
+
+.dg-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-2) var(--sp-3);
+ cursor: pointer;
+ transition: background 0.1s;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.dg-dropdown-item:last-child {
+ border-bottom: none;
+}
+
+.dg-dropdown-item:hover {
+ background: var(--accent-dim);
+}
+
+.dg-dropdown-item-img {
+ width: 32px;
+ height: 44px;
+ object-fit: cover;
+ border-radius: 3px;
+ flex-shrink: 0;
+ background: var(--surface-1);
+}
+
+.dg-dropdown-item-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.dg-dropdown-item-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dg-dropdown-item-type {
+ font-size: 11px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dg-dropdown-item-badge {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: var(--radius-md);
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.dg-badge-owned {
+ background: var(--success-dim);
+ color: var(--success);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+}
+
+.dg-badge-scryfall {
+ background: var(--accent-dim);
+ color: var(--accent);
+ border: 1px solid var(--accent-glow);
+}
+
+.dg-dropdown-empty {
+ text-align: center;
+ padding: var(--sp-4);
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.dg-dropdown-loading {
+ text-align: center;
+ padding: var(--sp-4);
+ color: var(--accent);
+ font-size: 12px;
+}
+
+/* Commander Selected Card */
+
+.dg-commander-selected {
+ margin-top: var(--sp-3);
+}
+
+.dg-commander-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-3);
+ background: var(--bg);
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ position: relative;
+}
+
+.dg-commander-img {
+ width: 100%;
+ max-width: 260px;
+ height: auto;
+ border-radius: 12px;
+ flex-shrink: 0;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+}
+
+.dg-commander-info {
+ text-align: center;
+ min-width: 0;
+ width: 100%;
+}
+
+.dg-commander-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: #fff;
+}
+
+.dg-commander-type {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.dg-commander-colors {
+ display: flex;
+ justify-content: center;
+ gap: 3px;
+ margin-top: var(--sp-1);
+}
+
+/* MTG mana color pips — domain-specific colors */
+.dg-color-pip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ font-size: 10px;
+ font-weight: 700;
+ box-shadow: var(--shadow-sm);
+}
+
+.dg-pip-W { background: #f9faf4; color: #3d3929; }
+.dg-pip-U { background: #0e67ab; color: #fff; }
+.dg-pip-B { background: #2b2424; color: #ccc; }
+.dg-pip-R { background: #d3202a; color: #fff; }
+.dg-pip-G { background: #00733e; color: #fff; }
+.dg-pip-C { background: #706f6f; color: #fff; }
+
+.dg-commander-clear {
+ position: absolute;
+ top: var(--sp-2);
+ right: var(--sp-2);
+ background: rgba(0, 0, 0, 0.6);
+ border: none;
+ color: #fff;
+ font-size: 18px;
+ cursor: pointer;
+ padding: 2px 8px;
+ line-height: 1;
+ border-radius: 50%;
+ z-index: 2;
+ transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
+}
+
+.dg-commander-clear:hover {
+ color: var(--error);
+}
+
+/* ── 6. Source Toggles ──────────────────────────────────── */
+
+.dg-source-toggles {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.dg-source-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-2) 0;
+}
+
+.dg-source-label {
+ font-size: 13px;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.dg-source-exp {
+ font-size: 9px;
+ padding: 1px 5px;
+ border-radius: var(--radius-sm);
+ background: var(--warning-dim);
+ color: var(--warning);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+ text-transform: uppercase;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+}
+
+/* ── 7. Toggle Switch ───────────────────────────────────── */
+
+.dg-toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--sp-2) 0;
+ cursor: pointer;
+ user-select: none;
+}
+
+.dg-toggle-label {
+ font-size: 13px;
+ color: var(--text);
+}
+
+.dg-toggle-check {
+ display: none;
+}
+
+.dg-toggle-track {
+ width: 34px;
+ height: 18px;
+ background: var(--border);
+ border-radius: 9px;
+ position: relative;
+ flex-shrink: 0;
+ transition: background 0.2s;
+}
+
+.dg-toggle-check:checked + .dg-toggle-track {
+ background: var(--accent);
+}
+
+.dg-toggle-thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.2s;
+}
+
+.dg-toggle-check:checked + .dg-toggle-track .dg-toggle-thumb {
+ transform: translateX(16px);
+}
+
+/* ── 8. Ratio Controls ──────────────────────────────────── */
+
+.dg-ratio-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.dg-ratio-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.dg-ratio-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ width: 80px;
+ flex-shrink: 0;
+ line-height: 1.3;
+}
+
+.dg-ratio-slider {
+ flex: 1;
+ min-width: 0;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 6px;
+ background: var(--border);
+ border-radius: 3px;
+ outline: none;
+ margin: 0 4px;
+}
+
+.dg-ratio-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+ border: 2px solid var(--bg);
+ box-shadow: 0 0 6px var(--accent-glow);
+ margin-top: -5px;
+}
+
+.dg-ratio-slider::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+ border: 2px solid var(--bg);
+}
+
+.dg-ratio-value {
+ width: 52px;
+ min-width: 52px;
+ text-align: center;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 4px 6px;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ flex-shrink: 0;
+ -moz-appearance: textfield;
+}
+
+.dg-ratio-value::-webkit-outer-spin-button,
+.dg-ratio-value::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.dg-ratio-value:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+/* ── 9. Buttons ─────────────────────────────────────────── */
+
+.dg-actions {
+ padding: var(--sp-1) 0 var(--sp-2);
+}
+
+.dg-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-3) var(--sp-5);
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--duration) var(--ease);
+ border: none;
+ font-family: inherit;
+}
+
+.dg-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.dg-btn-icon {
+ font-size: 14px;
+}
+
+.dg-btn-preview {
+ background: var(--success);
+ color: var(--text-inverse);
+ width: 100%;
+ justify-content: center;
+}
+
+.dg-btn-preview:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 16px var(--success-dim);
+}
+
+.dg-btn-save {
+ background: var(--accent);
+ color: var(--text-inverse);
+}
+
+.dg-btn-save:hover:not(:disabled) {
+ background: var(--accent-hover);
+ box-shadow: 0 4px 12px var(--accent-glow);
+}
+
+.dg-btn-ghost {
+ background: var(--surface-1);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.dg-btn-ghost:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* ── 10. Empty State ────────────────────────────────────── */
+
+.dg-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 400px;
+ text-align: center;
+ gap: var(--sp-3);
+}
+
+.dg-empty-icon {
+ font-size: 48px;
+ opacity: 0.25;
+ user-select: none;
+}
+
+.dg-empty-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.dg-empty-text {
+ font-size: 13px;
+ color: var(--text-secondary);
+ max-width: 300px;
+ line-height: 1.5;
+}
+
+/* ── 11. Loading State ──────────────────────────────────── */
+
+.dg-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+ gap: var(--sp-4);
+}
+
+.dg-spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: dg-spin 0.7s linear infinite;
+}
+
+@keyframes dg-spin {
+ to { transform: rotate(360deg); }
+}
+
+.dg-loading-text {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+/* ── 12. Results — Stats Bar ────────────────────────────── */
+
+.dg-stats-bar {
+ display: flex;
+ gap: var(--sp-3);
+ flex-wrap: wrap;
+ margin-bottom: var(--sp-4);
+}
+
+.dg-stat-chip {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ font-size: 12px;
+}
+
+.dg-stat-num {
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: #fff;
+}
+
+.dg-stat-label {
+ color: var(--text-secondary);
+}
+
+.dg-stat-owned .dg-stat-num { color: var(--success); }
+.dg-stat-proxy .dg-stat-num { color: var(--warning); }
+
+/* ── 13. Results — Card Groups ──────────────────────────── */
+
+.dg-card-groups {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+}
+
+.dg-card-group {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.dg-group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--sp-2) var(--sp-3);
+ background: var(--accent-dim);
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ user-select: none;
+ transition: background var(--duration) var(--ease);
+}
+
+.dg-group-header:hover {
+ background: rgba(91, 158, 240, 0.08);
+}
+
+.dg-group-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+}
+
+.dg-group-count {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.dg-group-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.dg-group-body.collapsed {
+ display: none;
+}
+
+.dg-card-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-2) var(--sp-3);
+ border-bottom: 1px solid var(--border-subtle);
+ transition: background 0.1s;
+ position: relative;
+}
+
+.dg-card-row:last-child {
+ border-bottom: none;
+}
+
+.dg-card-row:hover {
+ background: var(--accent-dim);
+}
+
+.dg-card-row-mana {
+ font-size: 12px;
+ color: var(--text-secondary);
+ width: 50px;
+ text-align: right;
+ flex-shrink: 0;
+ font-family: var(--font-mono);
+}
+
+.dg-card-row-name {
+ flex: 1;
+ font-size: 13px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dg-card-row-roles {
+ display: flex;
+ gap: 3px;
+ flex-shrink: 0;
+}
+
+.dg-role-chip {
+ font-size: 9px;
+ padding: 1px 5px;
+ border-radius: var(--radius-sm);
+ background: var(--accent-dim);
+ color: var(--accent);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ font-weight: 600;
+}
+
+.dg-card-row-source {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: var(--radius-md);
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.dg-src-collection {
+ background: var(--success-dim);
+ color: var(--success);
+}
+
+.dg-src-edhrec {
+ background: var(--purple-dim);
+ color: var(--purple);
+}
+
+.dg-src-archidekt {
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+
+.dg-src-moxfield {
+ background: var(--warning-dim);
+ color: var(--warning);
+}
+
+.dg-src-mtggoldfish {
+ background: var(--error-dim);
+ color: var(--error);
+}
+
+.dg-src-template {
+ background: rgba(139, 148, 158, 0.12);
+ color: var(--text-secondary);
+}
+
+.dg-card-row-owned {
+ font-size: 10px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+ width: 32px;
+ text-align: center;
+}
+
+.dg-card-row-owned.is-owned {
+ color: var(--success);
+ font-weight: 600;
+}
+
+.dg-card-row-owned.is-proxy {
+ color: var(--warning);
+}
+
+/* Card Image Preview on Hover */
+
+.dg-card-preview {
+ display: none;
+ position: fixed;
+ z-index: 60;
+ pointer-events: none;
+}
+
+.dg-card-preview.visible {
+ display: block;
+}
+
+.dg-card-preview img {
+ width: 240px;
+ border-radius: var(--radius-md);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
+}
+
+/* ── 14. Save Bar ───────────────────────────────────────── */
+
+.dg-save-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-4) 0 var(--sp-2);
+ position: sticky;
+ bottom: 0;
+ background: linear-gradient(transparent, var(--bg) 20%);
+ padding-top: var(--sp-6);
+}
+
+/* ── 15. Toast ──────────────────────────────────────────── */
+
+.dg-toast-container {
+ position: fixed;
+ bottom: var(--sp-5);
+ right: var(--sp-5);
+ z-index: 500;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.dg-toast {
+ padding: var(--sp-3) var(--sp-4);
+ border-radius: var(--radius-md);
+ font-size: 13px;
+ color: #fff;
+ animation: dg-toast-in 0.25s ease, dg-toast-out 0.3s ease 2.5s forwards;
+ max-width: 360px;
+ box-shadow: var(--shadow-md);
+}
+
+.dg-toast-success {
+ background: var(--success);
+}
+
+.dg-toast-error {
+ background: var(--error);
+}
+
+.dg-toast-info {
+ background: var(--accent);
+ color: var(--text-inverse);
+}
+
+@keyframes dg-toast-in {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes dg-toast-out {
+ from { opacity: 1; }
+ to { opacity: 0; transform: translateY(-8px); }
+}
+
+/* ── 16. Responsive ─────────────────────────────────────── */
+
+@media (max-width: 900px) {
+ .dg-main {
+ grid-template-columns: 1fr;
+ }
+
+ .dg-col-config {
+ height: auto;
+ position: static;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .dg-col-results {
+ max-height: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .dg-col-config {
+ padding: var(--sp-3);
+ }
+
+ .dg-col-results {
+ padding: var(--sp-3);
+ }
+
+ .dg-card-preview {
+ display: none !important;
+ }
+}
+
+/* ══════════════════════════════════════════════════════════════
+ V3 Additions — Bracket, Strategy, Substitution UI
+ ══════════════════════════════════════════════════════════════ */
+
+/* ── Bracket Buttons ──────────────────────────────────────── */
+.dg-bracket-row {
+ margin-top: var(--sp-3);
+}
+
+.dg-bracket-btns {
+ display: flex;
+ gap: var(--sp-2);
+ margin-top: var(--sp-2);
+}
+
+.dg-bracket-btn {
+ flex: 1;
+ padding: var(--sp-2) var(--sp-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-1);
+ color: var(--text-muted);
+ font-weight: 600;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.dg-bracket-btn:hover {
+ border-color: var(--accent);
+ color: var(--text);
+}
+
+.dg-bracket-btn.dg-bracket-active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+/* ── Budget Row ───────────────────────────────────────────── */
+.dg-budget-row {
+ display: flex;
+ gap: var(--sp-2);
+}
+
+.dg-input-narrow {
+ width: 120px;
+ flex-shrink: 0;
+}
+
+.dg-select {
+ flex: 1;
+ padding: var(--sp-2) var(--sp-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-1);
+ color: var(--text);
+ font-size: 0.85rem;
+}
+
+/* ── Model Row ────────────────────────────────────────────── */
+.dg-model-row {
+ margin-top: var(--sp-3);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+}
+
+.dg-model-row .dg-select {
+ flex: 1;
+}
+
+.dg-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+/* ── Strategy Bar ─────────────────────────────────────────── */
+.dg-strategy-bar {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-3) var(--sp-4);
+ margin-bottom: var(--sp-3);
+}
+
+.dg-strategy-info {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ flex-wrap: wrap;
+}
+
+.dg-bracket-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 24px;
+ border-radius: var(--radius-sm);
+ font-weight: 700;
+ font-size: 0.8rem;
+ color: #fff;
+ padding: 0 var(--sp-2);
+}
+
+.dg-bracket-1 { background: #4ade80; color: #0a0a0a; }
+.dg-bracket-2 { background: #60a5fa; }
+.dg-bracket-3 { background: #f59e0b; color: #0a0a0a; }
+.dg-bracket-4 { background: #ef4444; }
+
+.dg-archetype-badge {
+ background: var(--surface-2, #2a2a2a);
+ color: var(--text-muted);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.dg-strategy-text {
+ font-size: 0.85rem;
+ color: var(--text);
+}
+
+.dg-bracket-reasoning {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: var(--sp-2);
+ line-height: 1.4;
+}
+
+.dg-game-changers {
+ margin-top: var(--sp-2);
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.dg-gc-chip {
+ display: inline-block;
+ background: rgba(239, 68, 68, 0.15);
+ color: #fca5a5;
+ padding: 1px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 0.7rem;
+ margin-left: var(--sp-1);
+}
+
+/* ── Substitution Summary ─────────────────────────────────── */
+.dg-sub-summary {
+ margin-bottom: var(--sp-3);
+}
+
+.dg-sub-bar {
+ display: flex;
+ gap: var(--sp-3);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-2) var(--sp-4);
+}
+
+.dg-sub-stat {
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.dg-sub-owned { color: #4ade80; }
+.dg-sub-substituted { color: #60a5fa; }
+.dg-sub-missing { color: #f59e0b; }
+
+/* ── Card Status Badges ───────────────────────────────────── */
+.dg-card-status {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.dg-badge-owned {
+ background: rgba(74, 222, 128, 0.15);
+ color: #4ade80;
+}
+
+.dg-badge-substituted {
+ background: rgba(96, 165, 250, 0.15);
+ color: #60a5fa;
+}
+
+.dg-badge-missing {
+ background: rgba(245, 158, 11, 0.15);
+ color: #f59e0b;
+}
+
+/* ── Card Row V3 ──────────────────────────────────────────── */
+.dg-card-row {
+ padding: var(--sp-2) var(--sp-3);
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+}
+
+.dg-card-row-main {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.dg-card-row-name {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 0.85rem;
+}
+
+.dg-card-count {
+ display: inline-block;
+ background: var(--accent, #6c63ff);
+ color: #fff;
+ border-radius: 4px;
+ padding: 1px 5px;
+ font-size: 0.7rem;
+ font-weight: 700;
+ margin-right: 2px;
+ min-width: 20px;
+ text-align: center;
+}
+
+.dg-card-row-roles {
+ display: flex;
+ gap: 3px;
+ flex-shrink: 0;
+}
+
+.dg-card-price {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.dg-card-reason {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ padding: 2px 0 0 var(--sp-3);
+ line-height: 1.3;
+}
+
+/* ── Substitution Lines ───────────────────────────────────── */
+.dg-card-sub-line {
+ padding: 2px 0 0 var(--sp-3);
+ font-size: 0.75rem;
+}
+
+.dg-sub-arrow {
+ color: #60a5fa;
+ font-weight: 700;
+}
+
+.dg-sub-name {
+ color: #60a5fa;
+ font-weight: 600;
+}
+
+.dg-sub-label {
+ color: var(--text-muted);
+}
+
+.dg-alt-chip {
+ display: inline-block;
+ background: rgba(255,255,255,0.05);
+ padding: 1px 5px;
+ border-radius: var(--radius-sm);
+ font-size: 0.65rem;
+ margin-left: 3px;
+ color: var(--text-muted);
+ cursor: help;
+}
+
+/* ── Card row status striping ─────────────────────────────── */
+.dg-card-status-owned {}
+.dg-card-status-substituted {
+ background: rgba(96, 165, 250, 0.04);
+ border-left: 2px solid rgba(96, 165, 250, 0.3);
+}
+.dg-card-status-missing {
+ background: rgba(245, 158, 11, 0.04);
+ border-left: 2px solid rgba(245, 158, 11, 0.3);
+}
+
+/* ── Commander row highlight ──────────────────────────────── */
+.dg-card-row-commander {
+ background: rgba(139, 92, 246, 0.08);
+ border-left: 2px solid rgba(139, 92, 246, 0.5);
+}
+
+/* ── Export Buttons ────────────────────────────────────────── */
+.dg-export-group {
+ display: flex;
+ gap: var(--sp-2);
+ margin-left: auto;
+}
+
+.dg-btn-export {
+ padding: var(--sp-2) var(--sp-3) !important;
+ font-size: 0.75rem !important;
+ background: var(--surface-1) !important;
+ border: 1px solid var(--border) !important;
+ color: var(--text-muted) !important;
+}
+
+.dg-btn-export:hover {
+ border-color: var(--accent) !important;
+ color: var(--text) !important;
+}
+
+/* ── Stats additions ──────────────────────────────────────── */
+.dg-stat-substituted .dg-stat-num { color: #60a5fa; }
+.dg-stat-missing .dg-stat-num { color: #f59e0b; }
+.dg-stat-price .dg-stat-num { color: #a78bfa; }
+
+
+/* -- Conversation Log ---------------------------------------- */
+.dg-conversation-log {
+ margin-top: var(--sp-3);
+ max-height: 200px;
+ overflow-y: auto;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: var(--sp-3);
+ font-family: 'Courier New', monospace;
+ font-size: 0.8rem;
+ scrollbar-width: thin;
+}
+
+.dg-log-entry {
+ padding: 3px 0;
+ line-height: 1.5;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+}
+
+.dg-log-entry:last-child {
+ border-bottom: none;
+}
+
+.dg-log-ts {
+ color: var(--text-muted);
+ margin-right: 6px;
+}
+
+.dg-log-info {
+ color: var(--text);
+}
+
+.dg-log-success {
+ color: #60a5fa;
+}
+
+.dg-log-error {
+ color: #f59e0b;
+}
+
+/* ── Progress Bar (Deck Generation) ──────────────────── */
+.dg-progress-wrap {
+ width: 100%;
+ max-width: 400px;
+ margin-top: 16px;
+}
+
+.dg-progress-bar {
+ height: 6px;
+ background: var(--surface-2, #2a2a3a);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.dg-progress-fill {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa);
+ border-radius: 3px;
+ transition: width 1s ease;
+}
+
+.dg-progress-meta {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 6px;
+ font-size: 0.78rem;
+}
+
+.dg-progress-phase {
+ color: var(--text-muted, #999);
+}
+
+.dg-progress-elapsed {
+ color: var(--text-muted, #999);
+ font-variant-numeric: tabular-nums;
+}
+
+/* — Omit Cards Chips ————————————————————————— */
+.dg-omit-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ border: 1px solid var(--border, #333);
+ border-radius: 6px;
+ background: var(--bg-input, #1a1a2e);
+ min-height: 38px;
+ cursor: text;
+}
+
+.dg-omit-wrap:focus-within {
+ border-color: var(--accent, #6c5ce7);
+ box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25);
+}
+
+.dg-omit-chips {
+ display: contents;
+}
+
+.dg-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 12px;
+ background: var(--accent, #6c5ce7);
+ color: #fff;
+ font-size: 0.8rem;
+ white-space: nowrap;
+ line-height: 1.6;
+}
+
+.dg-chip-x {
+ cursor: pointer;
+ font-size: 0.9rem;
+ opacity: 0.7;
+ margin-left: 2px;
+}
+
+.dg-chip-x:hover {
+ opacity: 1;
+}
+
+.dg-omit-input {
+ flex: 1 1 120px;
+ border: none !important;
+ background: transparent !important;
+ padding: 2px 4px !important;
+ min-width: 120px;
+ outline: none;
+ box-shadow: none !important;
+}
+
+.dg-hint {
+ display: block;
+ margin-top: 6px;
+ color: var(--text-muted, #999);
+ font-size: 0.75rem;
+}
diff --git a/lan-client/deckgenerator.html b/lan-client/deckgenerator.html
new file mode 100644
index 0000000..ee89c05
--- /dev/null
+++ b/lan-client/deckgenerator.html
@@ -0,0 +1,231 @@
+
+
+
+
+
+ Commander AI Lab — Auto Deck Generator V3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
×
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Target Bracket
+ ?
+
+
+ B1
+ B2
+ B3
+ B4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Deck $
+ Per Card Max $
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cards listed here will be excluded from the generated deck.
+
+
+
+
+
+
+ ⚡ Generate Deck
+
+
+
+
+
+
[Ready] Waiting for deck generation...
+
+
+
+
+
+
+
+
+
⚡
+
V3 AI Deck Generator
+
Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.
+
+
+
+
+
+
Generating deck via Local AI...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lan-client/deckgenerator.js b/lan-client/deckgenerator.js
new file mode 100644
index 0000000..711cdf2
--- /dev/null
+++ b/lan-client/deckgenerator.js
@@ -0,0 +1,792 @@
+/**
+ * Commander AI Lab — Auto Deck Generator V3 UI
+ * ═══════════════════════════════════════════════
+ *
+ * V3 Endpoints:
+ * GET /api/deck/v3/status — check V3 generator status
+ * GET /api/deck-generator/commander-search — autocomplete commanders
+ * POST /api/deck/v3/generate — generate deck (local AI structured output)
+ * POST /api/deck/v3/commit — generate + save to Deck Builder
+ * POST /api/deck/v3/export/csv — export as CSV
+ * POST /api/deck/v3/export/dck — export as Forge .dck
+ * POST /api/deck/v3/export/moxfield — export in Moxfield paste format
+ * POST /api/deck/v3/export/shopping — shopping list of missing cards
+ */
+
+const DeckGenerator = (() => {
+ 'use strict';
+
+ const API_BASE = window.LAB_API_BASE || window.location.origin;
+ const DEBOUNCE_MS = 350;
+
+ const COLOR_MAP = {
+ W: { label: 'W', cls: 'dg-pip-W' },
+ U: { label: 'U', cls: 'dg-pip-U' },
+ B: { label: 'B', cls: 'dg-pip-B' },
+ R: { label: 'R', cls: 'dg-pip-R' },
+ G: { label: 'G', cls: 'dg-pip-G' },
+ C: { label: 'C', cls: 'dg-pip-C' },
+ };
+
+ const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
+ const TYPE_ICONS = {
+ 'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
+ 'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
+ 'Other': '🃏',
+ };
+
+ const STATUS_BADGES = {
+ owned: { label: 'Owned', cls: 'dg-badge-owned' },
+ substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
+ missing: { label: 'Missing', cls: 'dg-badge-missing' },
+ };
+
+ // ── State ──────────────────────────────────────────────
+ let state = {
+ commander: null,
+ targetBracket: 3,
+ previewResult: null,
+ lastRequestBody: null,
+ isLoading: false,
+ v3Ready: false,
+ };
+
+ let searchTimeout = null;
+
+ // ── Progress bar state ──────────────────────────────
+ const PROGRESS_PHASES = [
+ { at: 0, pct: 5, msg: 'Connecting to AI model...' },
+ { at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
+ { at: 6, pct: 18, msg: 'Building collection summary...' },
+ { at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
+ { at: 20, pct: 40, msg: 'AI is building the 99...' },
+ { at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
+ { at: 50, pct: 65, msg: 'Running smart substitution...' },
+ { at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
+ { at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
+ { at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
+ ];
+ let _progressInterval = null;
+ let _progressStart = 0;
+
+ function startProgress() {
+ _progressStart = Date.now();
+ const wrap = $('dg-progress-wrap');
+ const fill = $('dg-progress-fill');
+ const phase = $('dg-progress-phase');
+ const elapsed = $('dg-progress-elapsed');
+ if (!wrap) return;
+ wrap.style.display = 'block';
+ fill.style.width = '0%';
+ phase.textContent = '';
+ elapsed.textContent = '0s elapsed';
+ let lastPhaseIdx = -1;
+ _progressInterval = setInterval(() => {
+ const secs = Math.floor((Date.now() - _progressStart) / 1000);
+ elapsed.textContent = secs + 's elapsed';
+ for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
+ if (secs >= PROGRESS_PHASES[i].at) {
+ fill.style.width = PROGRESS_PHASES[i].pct + '%';
+ if (i !== lastPhaseIdx) {
+ phase.textContent = PROGRESS_PHASES[i].msg;
+ logMessage(PROGRESS_PHASES[i].msg, 'info');
+ lastPhaseIdx = i;
+ }
+ break;
+ }
+ }
+ }, 1000);
+ }
+
+ function stopProgress(success) {
+ if (_progressInterval) {
+ clearInterval(_progressInterval);
+ _progressInterval = null;
+ }
+ const fill = $('dg-progress-fill');
+ const phase = $('dg-progress-phase');
+ const elapsed = $('dg-progress-elapsed');
+ if (fill) fill.style.width = success ? '100%' : '0%';
+ if (phase) phase.textContent = success ? 'Done!' : 'Failed';
+ const secs = Math.floor((Date.now() - _progressStart) / 1000);
+ if (elapsed) elapsed.textContent = secs + 's total';
+ }
+
+ // ── Omit Cards (chips) ─────────────────────────────
+ let omitCards = [];
+
+ function addOmitCard(name) {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
+ omitCards.push(trimmed);
+ renderOmitChips();
+ }
+
+ function removeOmitCard(idx) {
+ omitCards.splice(idx, 1);
+ renderOmitChips();
+ }
+
+ function renderOmitChips() {
+ const container = $('dg-omit-chips');
+ if (!container) return;
+ container.innerHTML = omitCards.map((name, i) =>
+ '' +
+ escHtml(name) +
+ '× ' +
+ ' '
+ ).join('');
+ container.querySelectorAll('.dg-chip-x').forEach(el => {
+ el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
+ });
+ }
+
+ const $ = (id) => document.getElementById(id);
+
+ // ── Init ────────────────────────────────────────────────
+ async function init() {
+ await checkV3Status();
+ bindEvents();
+ checkQueryParams();
+ }
+
+ async function checkV3Status() {
+ try {
+ const resp = await fetch(API_BASE + '/api/deck/v3/status');
+ const data = await resp.json();
+ state.v3Ready = data.initialized;
+ if (!state.v3Ready) {
+ // Warn but do not block — local/Ollama mode may still work
+ console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
+ toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
+ }
+ } catch (e) {
+ console.warn('V3 status check failed:', e);
+ }
+ }
+
+ function bindEvents() {
+ // Commander search
+ const input = $('dg-commander-input');
+ input.addEventListener('input', () => {
+ clearTimeout(searchTimeout);
+ const q = input.value.trim();
+ if (q.length < 2) { hideDropdown(); return; }
+ searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
+ });
+
+ input.addEventListener('focus', () => {
+ const q = input.value.trim();
+ if (q.length >= 2) searchCommander(q);
+ });
+
+ document.addEventListener('click', (e) => {
+ const wrap = document.querySelector('.dg-commander-search-wrap');
+ if (wrap && !wrap.contains(e.target)) hideDropdown();
+ });
+
+ $('dg-commander-clear').addEventListener('click', clearCommander);
+
+ // Bracket buttons
+ document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
+ btn.classList.add('dg-bracket-active');
+ state.targetBracket = parseInt(btn.dataset.bracket, 10);
+ });
+ });
+
+ // Generate & save
+ $('dg-preview-btn').addEventListener('click', generateDeck);
+ $('dg-save-btn').addEventListener('click', commitDeck);
+ $('dg-regenerate-btn').addEventListener('click', generateDeck);
+
+ // Export buttons
+ $('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
+ $('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
+ $('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
+ $('dg-export-shopping').addEventListener('click', () => exportShopping());
+
+ // Omit cards chips input
+ const omitInput = $('dg-omit-input');
+ if (omitInput) {
+ omitInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addOmitCard(omitInput.value);
+ omitInput.value = '';
+ }
+ });
+ }
+ const omitWrap = $('dg-omit-wrap');
+ if (omitWrap) {
+ omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
+ }
+ }
+
+ function checkQueryParams() {
+ const params = new URLSearchParams(window.location.search);
+ const name = params.get('commander');
+ if (name) {
+ $('dg-commander-input').value = name;
+ searchCommander(name);
+ }
+ }
+
+ // ── Build Request Body ──────────────────────────────────
+ function buildRequestBody() {
+ const budget = parseFloat($('dg-budget').value) || null;
+ return {
+ commander_name: state.commander.name,
+ strategy: $('dg-strategy').value.trim(),
+ target_bracket: state.targetBracket,
+ budget_usd: budget,
+ budget_mode: $('dg-budget-mode').value,
+ omit_cards: omitCards.slice(),
+ use_collection: $('dg-use-collection').checked,
+ run_substitution: $('dg-substitution').checked,
+ model: $('dg-model').value,
+ deck_name: $('dg-deck-name').value.trim() || '',
+ };
+ }
+
+ // ── Commander Search ────────────────────────────────────
+ function positionDropdown() {
+ const input = $('dg-commander-input');
+ const dropdown = $('dg-commander-dropdown');
+ const rect = input.getBoundingClientRect();
+ dropdown.style.top = (rect.bottom + 4) + 'px';
+ dropdown.style.left = rect.left + 'px';
+ dropdown.style.width = rect.width + 'px';
+ }
+
+ async function searchCommander(q) {
+ const dropdown = $('dg-commander-dropdown');
+ dropdown.innerHTML = 'Searching...
';
+ dropdown.classList.add('open');
+ positionDropdown();
+
+ try {
+ const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
+ const data = await resp.json();
+ renderDropdown(data.results || []);
+ } catch (e) {
+ dropdown.innerHTML = 'Search failed
';
+ }
+ }
+
+ function renderDropdown(results) {
+ const dropdown = $('dg-commander-dropdown');
+
+ if (results.length === 0) {
+ dropdown.innerHTML = 'No legendary creatures found
';
+ return;
+ }
+
+ dropdown.innerHTML = results.map((r, idx) => {
+ const colors = (r.color_identity || [])
+ .map(c => COLOR_MAP[c] ? '' + COLOR_MAP[c].label + ' ' : '')
+ .join('');
+
+ const badge = r.in_collection
+ ? 'Owned '
+ : 'Scryfall ';
+
+ const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
+ const imgTag = imgSrc
+ ? ' '
+ : '
';
+
+ return ''
+ + imgTag
+ + '
'
+ + '
' + escHtml(r.name) + '
'
+ + '
' + escHtml(r.type_line || '') + '
'
+ + '
'
+ + '
' + colors + '
'
+ + badge
+ + '
';
+ }).join('');
+
+ dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
+ el.addEventListener('click', () => {
+ selectCommander(results[parseInt(el.dataset.idx, 10)]);
+ hideDropdown();
+ });
+ });
+ }
+
+ function selectCommander(cmdr) {
+ state.commander = cmdr;
+ $('dg-commander-input').style.display = 'none';
+ $('dg-commander-selected').style.display = 'block';
+ $('dg-commander-name').textContent = cmdr.name;
+ $('dg-commander-type').textContent = cmdr.type_line || '';
+
+ $('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
+ .map(c => COLOR_MAP[c] ? '' + COLOR_MAP[c].label + ' ' : '')
+ .join('');
+
+ const img = $('dg-commander-img');
+ if (cmdr.image_url) {
+ var imgUrl = cmdr.image_url;
+ if (imgUrl.indexOf('version=small') !== -1) {
+ imgUrl = imgUrl.replace('version=small', 'version=normal');
+ } else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
+ imgUrl += '&version=normal';
+ } else if (imgUrl.indexOf('version=') === -1) {
+ imgUrl += '?version=normal';
+ }
+ img.src = imgUrl;
+ img.style.display = 'block';
+ } else {
+ img.style.display = 'none';
+ }
+
+ $('dg-preview-btn').disabled = false;
+
+ // Auto-fill deck name if empty
+ const deckNameInput = $('dg-deck-name');
+ if (deckNameInput && !deckNameInput.value.trim()) {
+ deckNameInput.value = 'Auto - ' + cmdr.name;
+ }
+ }
+
+ function clearCommander() {
+ state.commander = null;
+ $('dg-commander-input').style.display = '';
+ $('dg-commander-input').value = '';
+ $('dg-commander-selected').style.display = 'none';
+ $('dg-preview-btn').disabled = true;
+ }
+
+ function hideDropdown() {
+ $('dg-commander-dropdown').classList.remove('open');
+ }
+
+ // -- Conversation Log Helper --
+ function logMessage(msg, type = 'info') {
+ const log = $('dg-conversation-log');
+ if (!log) return;
+ const entry = document.createElement('div');
+ entry.className = 'dg-log-entry dg-log-' + type;
+ const ts = new Date().toLocaleTimeString();
+ entry.innerHTML = '[' + ts + '] ' + msg;
+ log.appendChild(entry);
+ log.scrollTop = log.scrollHeight;
+ }
+
+ function clearLog() {
+ const log = $('dg-conversation-log');
+ if (!log) return;
+ log.innerHTML = '';
+ }
+
+ // ── Generate Deck ───────────────────────────────────────
+ async function generateDeck() {
+ clearLog();
+ logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info');
+ if (!state.commander) { toast('Select a commander first', 'error'); return; }
+ // Non-blocking warning only — local Ollama mode does not require v3Ready
+ if (!state.v3Ready) {
+ logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
+ }
+ logMessage('Building request body with commander: ' + state.commander.name + ' ', 'info');
+
+ setLoading(true, 'Generating deck via Local AI...');
+ startProgress();
+
+ const body = buildRequestBody();
+ logMessage('Sending request to AI model... this may take a moment.', 'info');
+ state.lastRequestBody = body;
+
+ try {
+ const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ throw new Error(err.error || err.detail || 'Generation failed');
+ }
+
+ state.previewResult = await resp.json();
+ logMessage('AI response received! Processing deck data...', 'success');
+
+ const stats = state.previewResult.stats || {};
+ const cardCount = (stats.total_cards || 0);
+ logMessage('Deck generated: ' + cardCount + ' cards | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
+ stopProgress(true);
+ renderResults(state.previewResult);
+
+ toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
+ } catch (e) {
+ stopProgress(false);
+ toast(e.message || 'Generation failed', 'error');
+ logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
+ setLoading(false);
+ }
+ }
+
+ function setLoading(loading, text) {
+ state.isLoading = loading;
+ $('dg-empty-state').style.display = 'none';
+ $('dg-loading').style.display = loading ? 'flex' : 'none';
+ $('dg-results').style.display = loading ? 'none' : 'none';
+ $('dg-preview-btn').disabled = loading || !state.commander;
+ if (text) $('dg-loading-text').textContent = text;
+ }
+
+ // ── Render Results ──────────────────────────────────────
+ function renderResults(result) {
+ $('dg-loading').style.display = 'none';
+ $('dg-results').style.display = 'block';
+ $('dg-preview-btn').disabled = false;
+
+ renderStrategyBar(result);
+ renderStatsBar(result);
+ renderSubstitutionSummary(result);
+ renderCardGroups(result);
+ renderUseInGameButton(result);
+ }
+
+ function renderUseInGameButton(result) {
+ // Only show when running inside the LAN client iframe
+ if (window.parent === window) return;
+
+ var existing = document.getElementById('dg-export-game-btn');
+ if (existing) existing.remove();
+
+ var btn = document.createElement('button');
+ btn.id = 'dg-export-game-btn';
+ btn.className = 'dg-btn dg-btn-preview';
+ btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
+ btn.innerHTML = '🎮 Use in Game';
+ btn.addEventListener('click', function() {
+ var cards = (result.cards || []).map(function(c) {
+ return { name: c.name, quantity: c.count || 1, is_commander: false };
+ });
+ if (result.commander) {
+ cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
+ }
+ window.parent.postMessage({
+ type: 'DECK_BUILDER_EXPORT',
+ deck: cards,
+ deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
+ commander: result.commander ? result.commander.name : '',
+ }, '*');
+ toast('Deck sent to game setup!', 'success');
+ });
+
+ $('dg-results').appendChild(btn);
+ }
+
+ function renderStrategyBar(result) {
+ const bar = $('dg-strategy-bar');
+ const bracket = result.bracket || {};
+ const archetype = result.archetype || '';
+ const strategy = result.strategy_summary || '';
+
+ bar.innerHTML = ''
+ + 'B' + (bracket.level || '?') + ' '
+ + '' + escHtml(archetype) + ' '
+ + '' + escHtml(strategy) + ' '
+ + '
'
+ + (bracket.reasoning ? '' + escHtml(bracket.reasoning) + '
' : '')
+ + (bracket.game_changers && bracket.game_changers.length
+ ? 'Game Changers: ' + bracket.game_changers.map(c => '' + escHtml(c) + ' ').join('') + '
'
+ : '');
+ }
+
+ function renderStatsBar(result) {
+ const stats = result.stats || {};
+ const container = $('dg-stats-bar');
+
+ const chips = [
+ chipHtml('Total', stats.total_cards || 0, ''),
+ chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
+ chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
+ chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
+ chipHtml('Lands', stats.land_count || 0, ''),
+ chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
+ ];
+
+ container.innerHTML = chips.join('');
+ }
+
+ function chipHtml(label, num, cls) {
+ return ''
+ + '' + num + ' '
+ + '' + label + ' '
+ + '
';
+ }
+
+ function renderSubstitutionSummary(result) {
+ const el = $('dg-sub-summary');
+ const sub = result.substitution_stats;
+ if (!sub) { el.style.display = 'none'; return; }
+
+ el.style.display = 'block';
+ el.innerHTML = ''
+ + '' + sub.owned + ' owned '
+ + '' + sub.substituted + ' substituted '
+ + '' + sub.missing + ' still missing '
+ + '
';
+ }
+
+ function renderCardGroups(result) {
+ const container = $('dg-card-groups');
+ const cards = result.cards || [];
+ const commander = result.commander;
+
+ const groups = {};
+ for (const card of cards) {
+ const cat = card.category || 'Other';
+ if (!groups[cat]) groups[cat] = [];
+ groups[cat].push(card);
+ }
+
+ const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
+ for (const t of Object.keys(groups)) {
+ if (!sortedTypes.includes(t)) sortedTypes.push(t);
+ }
+
+ let html = '';
+
+ if (commander) {
+ html += ''
+ + ''
+ + '
'
+ + '
'
+ + '' + escHtml(commander.name) + ' '
+ + 'Commander '
+ + '
'
+ + '
';
+ }
+
+ for (const type of sortedTypes) {
+ const groupCards = groups[type];
+ if (!groupCards || groupCards.length === 0) continue;
+
+ groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+
+ const icon = TYPE_ICONS[type] || '🃏';
+ html += ''
+ + ''
+ + '
';
+
+ for (const card of groupCards) {
+ html += renderCardRow(card);
+ }
+
+ html += '
';
+ }
+
+ container.innerHTML = html;
+
+ container.querySelectorAll('.dg-group-header').forEach(header => {
+ header.addEventListener('click', () => {
+ const type = header.dataset.type;
+ if (!type) return;
+ const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
+ if (body) body.classList.toggle('collapsed');
+ });
+ });
+ }
+
+ function renderCardRow(card) {
+ const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
+ const statusBadge = '' + statusInfo.label + ' ';
+
+ const roleTags = (card.role_tags || []).slice(0, 3)
+ .map(r => '' + escHtml(r) + ' ')
+ .join('');
+
+ let subHtml = '';
+ if (card.status === 'substituted' && card.selected_substitute) {
+ subHtml = ''
+ + '→ '
+ + '' + escHtml(card.selected_substitute) + ' '
+ + '
';
+ } else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
+ subHtml = ''
+ + 'Alternatives: '
+ + card.alternatives.slice(0, 3).map(a =>
+ ''
+ + escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
+ + ' '
+ ).join('')
+ + '
';
+ }
+
+ const price = card.estimated_price_usd
+ ? '$' + card.estimated_price_usd.toFixed(2) + ' '
+ : '';
+
+ return ''
+ + '
'
+ + '' + ((card.count || 1) > 1 ? '' + card.count + 'x ' : '') + escHtml(card.name) + ' '
+ + '' + roleTags + ' '
+ + statusBadge
+ + price
+ + '
'
+ + (card.reason ? '
' + escHtml(card.reason) + '
' : '')
+ + subHtml
+ + '
';
+ }
+
+ // ── Commit Deck ─────────────────────────────────────────
+ async function commitDeck() {
+ if (!state.commander) { toast('No commander selected', 'error'); return; }
+
+ $('dg-save-btn').disabled = true;
+ $('dg-save-btn').textContent = 'Saving...';
+
+ try {
+ const body = state.lastRequestBody || buildRequestBody();
+ const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ throw new Error(err.error || err.detail || 'Save failed');
+ }
+
+ const result = await resp.json();
+ toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
+
+ setTimeout(() => {
+ window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
+ }, 1200);
+ } catch (e) {
+ toast(e.message || 'Save failed', 'error');
+ } finally {
+ $('dg-save-btn').disabled = false;
+ $('dg-save-btn').innerHTML = '💾 Save to Deck Builder';
+ }
+ }
+
+ // ── Exports ─────────────────────────────────────────────
+ async function exportDeck(format) {
+ if (!state.commander) { toast('Generate a deck first', 'error'); return; }
+
+ const body = state.lastRequestBody || buildRequestBody();
+
+ try {
+ const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!resp.ok) throw new Error('Export failed');
+
+ const blob = await resp.blob();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
+ a.click();
+ URL.revokeObjectURL(url);
+
+ toast('Exported ' + format.toUpperCase(), 'success');
+ } catch (e) {
+ toast(e.message || 'Export failed', 'error');
+ }
+ }
+
+ async function exportMoxfield() {
+ if (!state.commander) { toast('Generate a deck first', 'error'); return; }
+
+ const body = state.lastRequestBody || buildRequestBody();
+
+ try {
+ const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ const data = await resp.json();
+ if (data.content) {
+ await navigator.clipboard.writeText(data.content);
+ toast('Moxfield deck copied to clipboard', 'success');
+ }
+ } catch (e) {
+ toast('Moxfield export failed', 'error');
+ }
+ }
+
+ async function exportShopping() {
+ if (!state.commander) { toast('Generate a deck first', 'error'); return; }
+
+ const body = state.lastRequestBody || buildRequestBody();
+
+ try {
+ const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ const data = await resp.json();
+ const list = data.shopping_list || [];
+
+ if (list.length === 0) {
+ toast('All cards are owned — nothing to buy', 'success');
+ return;
+ }
+
+ const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
+ for (const item of list) {
+ lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
+ }
+ lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
+
+ await navigator.clipboard.writeText(lines.join('\n'));
+ toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
+ } catch (e) {
+ toast('Shopping list failed', 'error');
+ }
+ }
+
+ // ── Toast ───────────────────────────────────────────────
+ function toast(msg, type) {
+ type = type || 'info';
+ const container = $('dg-toast-container');
+ const el = document.createElement('div');
+ el.className = 'dg-toast dg-toast-' + type;
+ el.textContent = msg;
+ container.appendChild(el);
+ setTimeout(() => el.remove(), 4000);
+ }
+
+ // ── Helpers ─────────────────────────────────────────────
+ function escHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ }
+
+ function escAttr(str) {
+ return str.replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>');
+ }
+
+ return { init };
+})();
+
+document.addEventListener('DOMContentLoaded', () => {
+ DeckGenerator.init();
+});
diff --git a/lan-client/engine.js b/lan-client/engine.js
index 3093d28..e35af64 100644
--- a/lan-client/engine.js
+++ b/lan-client/engine.js
@@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) {
// Helper: find the card on battlefield for counter placement
function findCardOnBattlefield(cardName, ownerIdx) {
- var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
+ var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; });
for (var i = 0; i < bf.length; i++) {
if (bf[i].name === cardName) return bf[i];
}
@@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) {
var opps4 = getOpponents(oi);
var placed = false;
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
- var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
+ var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; });
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
cmBf[cmj].counters = cmBf[cmj].counters || {};
@@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) {
case 'counter_all': {
// +1/+1 counter on each creature you control
- var caBf = (gameState.players[oi] || {}).battlefield || [];
+ var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var caCount = 0;
for (var cai = 0; cai < caBf.length; cai++) {
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
@@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) {
}
case 'untap_all': {
- var uaBf = (gameState.players[oi] || {}).battlefield || [];
+ var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
var uaCount = 0;
for (var uai = 0; uai < uaBf.length; uai++) {
if (uaBf[uai].tapped) {
@@ -12779,6 +12779,9 @@ async function loadPreconForPlayer(playerIdx, preconId) {
if (commandCards.length > 0) {
player.commanderCardName = commandCards[0].name || '';
}
+ if (precon.fileName) {
+ player.deckFileName = precon.fileName;
+ }
// Shuffle library
for (var si = libraryCards.length - 1; si > 0; si--) {
@@ -16225,6 +16228,72 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
return assignments;
}
+// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
+function aiDeclareAttacks(playerIdx) {
+ if (!gameState) return;
+ var player = gameState.players[playerIdx];
+ if (!player || player.eliminated) return;
+ var difficulty = gameState.aiDifficulty || 'easy';
+
+ var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
+ return !hasSummoningSickness(c);
+ });
+ if (creatures.length === 0) return;
+
+ var attackerCards = [];
+
+ if (difficulty === 'easy') {
+ attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
+ if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
+ } else {
+ var primaryTarget = aiPickAttackTarget(playerIdx);
+ if (primaryTarget === -1) return;
+
+ creatures.forEach(function(c) {
+ var eval_ = aiEvaluateAttack(c, primaryTarget);
+ if (difficulty === 'normal') {
+ if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
+ } else {
+ if (eval_.shouldAttack) attackerCards.push(c);
+ else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
+ }
+ });
+
+ if (difficulty === 'hard' && attackerCards.length > 2) {
+ var sortedByToughness = attackerCards.slice().sort(function(a, b) {
+ return getCreatureToughness(b) - getCreatureToughness(a);
+ });
+ var holdBack = sortedByToughness.find(function(c) {
+ return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
+ });
+ if (holdBack) {
+ attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
+ }
+ }
+ }
+
+ if (attackerCards.length === 0) return;
+
+ var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
+ if (assignments.length === 0) return;
+
+ gameState.combat.attackers = assignments.map(function(a) {
+ return { cardId: a.card.id, targetPlayerId: a.targetIdx };
+ });
+
+ attackerCards.forEach(function(c) {
+ if (!hasKeyword(c, 'vigilance')) {
+ c.tapped = true;
+ var el = document.getElementById('bc-' + c.id);
+ if (el) el.classList.add('tapped');
+ }
+ var el2 = document.getElementById('bc-' + c.id);
+ if (el2) el2.classList.add('attacking');
+ });
+
+ fireBattlefieldTriggers('attack');
+}
+
// ---- AI Helper: Check if AI has a counterspell and should use it ----
function aiCheckForCounterspell(playerIdx) {
if (!gameState) return null;
@@ -16593,7 +16662,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
var postDelay = 600;
// MAIN PHASE 2 (same logic as inline version)
- if (difficulty !== 'easy') {
+ {
postActions.push({ delay: postDelay, fn: function() {
var bluffReserve = 0;
var bluffCardName = '';
@@ -17205,12 +17274,12 @@ function _runLocalAiTurn() {
}
// ========================================
- // MAIN PHASE 2: Post-combat spell casting (normal & hard)
+ // MAIN PHASE 2: Post-combat spell casting
// Hard: play held-back removal + instants + any remaining affordable spells
- // Normal: try to play any remaining affordable spells
+ // Normal/Easy: try to play any remaining affordable spells
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
// ========================================
- if (difficulty !== 'easy') {
+ {
actions.push({ delay: delay, fn: function() {
if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this
var bluffReserve = 0;
@@ -25634,3 +25703,9 @@ function _safeRender(fn, ...args) {
try { window[fn](...args); } catch(e) {}
}
}
+
+// Expose functions needed by ai-bridge.js
+window.aiTryCastCommander = aiTryCastCommander;
+window.aiDeclareAttacks = aiDeclareAttacks;
+window.playCardFromHand = playCardFromHand;
+window.aiCountUntappedLands = aiCountUntappedLands;
diff --git a/lan-client/global.css b/lan-client/global.css
new file mode 100644
index 0000000..79acbab
--- /dev/null
+++ b/lan-client/global.css
@@ -0,0 +1,955 @@
+/* ═══════════════════════════════════════════════════════════
+ Commander AI Lab — Global Design System
+ Dark & Polished · Premium Dashboard Aesthetic
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── Reset ──────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+/* ── Design Tokens ──────────────────────────────────────── */
+:root {
+ /* Surfaces — layered dark with subtle warmth */
+ --bg: #0c0e14;
+ --surface-0: #12141c;
+ --surface-1: #171a24;
+ --surface-2: #1c1f2b;
+ --surface-3: #222533;
+ --surface-hover: #252839;
+
+ /* Borders */
+ --border: #2a2d3a;
+ --border-subtle: #1e2130;
+ --border-focus: #4a6fa5;
+
+ /* Text */
+ --text: #e1e4eb;
+ --text-secondary:#8b90a0;
+ --text-tertiary: #5c6070;
+ --text-inverse: #0c0e14;
+
+ /* Accent — cool blue with subtle depth */
+ --accent: #5b9ef0;
+ --accent-hover: #7ab4ff;
+ --accent-dim: rgba(91, 158, 240, 0.12);
+ --accent-glow: rgba(91, 158, 240, 0.25);
+
+ /* Semantic */
+ --success: #4ade80;
+ --success-dim: rgba(74, 222, 128, 0.12);
+ --warning: #fbbf24;
+ --warning-dim: rgba(251, 191, 36, 0.12);
+ --error: #f87171;
+ --error-dim: rgba(248, 113, 113, 0.12);
+ --purple: #a78bfa;
+ --purple-dim: rgba(167, 139, 250, 0.12);
+ --gold: #fcd34d;
+
+ /* Radius */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+ --radius-xl: 20px;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
+ --shadow-md: 0 4px 16px rgba(0,0,0,0.35);
+ --shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
+ --shadow-glow: 0 0 20px var(--accent-dim);
+
+ /* Transitions */
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
+ --duration: 180ms;
+
+ /* Typography */
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
+
+ /* Spacing scale */
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
+ --sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
+ --sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
+ --sp-16: 64px;
+
+ /* Layout */
+ --content-max: 1280px;
+ --sidebar-width: 280px;
+ --nav-height: 56px;
+}
+
+/* ── Base ───────────────────────────────────────────────── */
+html {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ scroll-behavior: smooth;
+}
+
+body {
+ min-height: 100vh;
+ font-family: var(--font);
+ font-size: 14px;
+ line-height: 1.6;
+ color: var(--text);
+ background: var(--bg);
+}
+
+h1, h2, h3, h4, h5, h6 {
+ color: #fff;
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+h1 { font-size: 24px; }
+h2 { font-size: 18px; }
+h3 { font-size: 16px; }
+
+a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
+a:hover { color: var(--accent-hover); }
+
+img, svg { display: block; max-width: 100%; }
+button { cursor: pointer; font: inherit; }
+input, select, textarea { font: inherit; color: inherit; }
+table { border-collapse: collapse; width: 100%; }
+
+::selection {
+ background: var(--accent-dim);
+ color: #fff;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+/* ── Scrollbar ──────────────────────────────────────────── */
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
+
+/* ── Navigation ─────────────────────────────────────────── */
+.nav {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ height: var(--nav-height);
+ padding: 0 var(--sp-6);
+ background: rgba(12, 14, 20, 0.85);
+ backdrop-filter: blur(16px) saturate(1.5);
+ -webkit-backdrop-filter: blur(16px) saturate(1.5);
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.nav-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ font-size: 15px;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: -0.01em;
+ text-decoration: none;
+ white-space: nowrap;
+ margin-right: var(--sp-8);
+}
+
+.nav-brand svg {
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+}
+
+.nav-links {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-1);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.nav-links::-webkit-scrollbar { display: none; }
+
+.nav-links a {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 6px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+ transition: all var(--duration) var(--ease);
+ text-decoration: none;
+}
+
+.nav-links a:hover {
+ color: var(--text);
+ background: var(--surface-2);
+}
+
+.nav-links a.active {
+ color: #fff;
+ background: var(--accent-dim);
+}
+
+.nav-links a .nav-icon {
+ width: 16px;
+ height: 16px;
+ opacity: 0.7;
+}
+
+.nav-links a.active .nav-icon,
+.nav-links a:hover .nav-icon {
+ opacity: 1;
+}
+
+/* ── Page Layout ────────────────────────────────────────── */
+.page {
+ max-width: var(--content-max);
+ margin: 0 auto;
+ padding: var(--sp-8) var(--sp-6);
+}
+
+.page-header {
+ margin-bottom: var(--sp-8);
+}
+
+.page-header h1 {
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: var(--sp-2);
+}
+
+.page-header p {
+ color: var(--text-secondary);
+ font-size: 15px;
+}
+
+/* ── Cards / Panels ─────────────────────────────────────── */
+.panel {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--sp-6);
+ margin-bottom: var(--sp-6);
+ transition: border-color var(--duration) var(--ease);
+}
+
+.panel:hover {
+ border-color: var(--border-focus);
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--sp-5);
+}
+
+.panel-header h2 {
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.panel-header .badge {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* ── Stat Cards ─────────────────────────────────────────── */
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--sp-4);
+ margin-bottom: var(--sp-6);
+}
+
+.stat-card {
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--sp-5);
+ transition: all var(--duration) var(--ease);
+}
+
+.stat-card:hover {
+ border-color: var(--border-focus);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.stat-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: var(--sp-2);
+}
+
+.stat-value {
+ font-size: 28px;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+}
+
+.stat-sub {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ margin-top: var(--sp-1);
+}
+
+/* ── Buttons ────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+ padding: 8px 18px;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ border: none;
+ cursor: pointer;
+ transition: all var(--duration) var(--ease);
+ white-space: nowrap;
+ line-height: 1.4;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: var(--text-inverse);
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ box-shadow: 0 4px 14px var(--accent-glow);
+ transform: translateY(-1px);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+}
+
+.btn-primary:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.btn-secondary {
+ background: var(--surface-3);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ background: var(--surface-hover);
+ border-color: var(--text-tertiary);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ padding: 6px 12px;
+}
+
+.btn-ghost:hover {
+ color: var(--text);
+ background: var(--surface-2);
+}
+
+.btn-success { background: var(--success); color: var(--text-inverse); }
+.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
+
+.btn-purple { background: var(--purple); color: var(--text-inverse); }
+.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
+
+.btn-danger { background: var(--error); color: #fff; }
+.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
+
+.btn-sm { padding: 5px 12px; font-size: 12px; }
+.btn-lg { padding: 12px 24px; font-size: 15px; }
+.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
+
+/* ── Forms ──────────────────────────────────────────────── */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.form-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.form-input,
+.form-select,
+.form-textarea {
+ width: 100%;
+ padding: 9px 14px;
+ font-size: 14px;
+ color: var(--text);
+ background: var(--surface-0);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ transition: all var(--duration) var(--ease);
+}
+
+.form-input:focus,
+.form-select:focus,
+.form-textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-dim);
+ outline: none;
+}
+
+.form-input::placeholder,
+.form-textarea::placeholder {
+ color: var(--text-tertiary);
+}
+
+.form-select {
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ padding-right: 36px;
+}
+
+.form-textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.form-checkbox {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ font-size: 13px;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.form-checkbox input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: var(--sp-4);
+}
+
+/* ── Progress Bar ───────────────────────────────────────── */
+.progress-container {
+ margin-top: var(--sp-4);
+ padding: var(--sp-4);
+ background: var(--surface-0);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-subtle);
+}
+
+.progress-header {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ margin-bottom: var(--sp-3);
+}
+
+.progress-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text);
+}
+
+.progress-track {
+ height: 6px;
+ background: var(--surface-3);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent), var(--accent-hover));
+ border-radius: 3px;
+ transition: width 0.3s var(--ease);
+}
+
+.progress-detail {
+ margin-top: var(--sp-2);
+ font-size: 12px;
+ font-family: var(--font-mono);
+ color: var(--text-tertiary);
+ line-height: 1.6;
+}
+
+/* ── Phase Badge ────────────────────────────────────────── */
+.badge-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.badge-status::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
+.badge-idle::before { background: var(--text-tertiary); }
+
+.badge-running { background: var(--accent-dim); color: var(--accent); }
+.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
+
+.badge-success { background: var(--success-dim); color: var(--success); }
+.badge-success::before { background: var(--success); }
+
+.badge-error { background: var(--error-dim); color: var(--error); }
+.badge-error::before { background: var(--error); }
+
+.badge-warning { background: var(--warning-dim); color: var(--warning); }
+.badge-warning::before { background: var(--warning); }
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ── Empty State ────────────────────────────────────────── */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--sp-12) var(--sp-6);
+ text-align: center;
+ color: var(--text-tertiary);
+}
+
+.empty-state svg {
+ width: 48px;
+ height: 48px;
+ margin-bottom: var(--sp-4);
+ opacity: 0.3;
+}
+
+.empty-state p {
+ font-size: 14px;
+ margin-bottom: var(--sp-3);
+}
+
+/* ── Tables ─────────────────────────────────────────────── */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.data-table thead th {
+ text-align: left;
+ padding: var(--sp-3) var(--sp-4);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-0);
+}
+
+.data-table tbody td {
+ padding: var(--sp-3) var(--sp-4);
+ border-bottom: 1px solid var(--border-subtle);
+ vertical-align: middle;
+}
+
+.data-table tbody tr:hover {
+ background: var(--surface-2);
+}
+
+.data-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+/* ── Tags ───────────────────────────────────────────────── */
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+}
+
+.tag-blue { background: var(--accent-dim); color: var(--accent); }
+.tag-green { background: var(--success-dim); color: var(--success); }
+.tag-yellow { background: var(--warning-dim); color: var(--warning); }
+.tag-red { background: var(--error-dim); color: var(--error); }
+.tag-purple { background: var(--purple-dim); color: var(--purple); }
+
+/* ── Toast / Notification ───────────────────────────────── */
+.toast {
+ position: fixed;
+ bottom: var(--sp-6);
+ right: var(--sp-6);
+ padding: var(--sp-3) var(--sp-5);
+ background: var(--surface-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ font-size: 13px;
+ color: var(--text);
+ z-index: 1000;
+ animation: toast-in 0.3s var(--ease);
+}
+
+.toast-success { border-left: 3px solid var(--success); }
+.toast-error { border-left: 3px solid var(--error); }
+.toast-warning { border-left: 3px solid var(--warning); }
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Loading Spinner ────────────────────────────────────── */
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ── Divider ────────────────────────────────────────────── */
+.divider {
+ height: 1px;
+ background: var(--border);
+ margin: var(--sp-5) 0;
+}
+
+/* ── Utility Classes ────────────────────────────────────── */
+.hidden { display: none !important; }
+.text-accent { color: var(--accent); }
+.text-success { color: var(--success); }
+.text-warning { color: var(--warning); }
+.text-error { color: var(--error); }
+.text-muted { color: var(--text-secondary); }
+.text-faint { color: var(--text-tertiary); }
+.text-mono { font-family: var(--font-mono); }
+.text-sm { font-size: 12px; }
+.text-xs { font-size: 11px; }
+.font-bold { font-weight: 700; }
+.text-center { text-align: center; }
+.text-right { text-align: right; }
+.mt-2 { margin-top: var(--sp-2); }
+.mt-4 { margin-top: var(--sp-4); }
+.mt-6 { margin-top: var(--sp-6); }
+.mb-2 { margin-bottom: var(--sp-2); }
+.mb-4 { margin-bottom: var(--sp-4); }
+.mb-6 { margin-bottom: var(--sp-6); }
+.gap-2 { gap: var(--sp-2); }
+.gap-4 { gap: var(--sp-4); }
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.flex-1 { flex: 1; }
+.grid { display: grid; }
+.sr-only {
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
+ overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
+}
+
+/* ── Responsive ─────────────────────────────────────────── */
+@media (max-width: 768px) {
+ .page {
+ padding: var(--sp-5) var(--sp-4);
+ }
+
+ .nav {
+ padding: 0 var(--sp-4);
+ }
+
+ .nav-brand {
+ margin-right: var(--sp-4);
+ font-size: 13px;
+ }
+
+ .stat-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 480px) {
+ .stat-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ═══════════════════════════════════════════════════════════
+ PREMIUM UPGRADE LAYER
+ ═══════════════════════════════════════════════════════════ */
+
+/* ── Ambient background glow (body) ─────────────────────── */
+body {
+ background-image:
+ radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
+ radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
+ background-attachment: fixed;
+}
+
+/* ── Page title: subtle gradient text ───────────────────── */
+.page-header h1 {
+ background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* ── Nav: stronger glass + accent underline ─────────────── */
+.nav {
+ background: rgba(9,11,16,0.80);
+ backdrop-filter: blur(20px) saturate(1.8);
+ -webkit-backdrop-filter: blur(20px) saturate(1.8);
+ box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
+ border-bottom-color: rgba(255,255,255,0.06);
+}
+.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
+.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
+
+/* ── Panels: gradient border on hover ───────────────────── */
+.panel {
+ position: relative;
+ transition: border-color var(--duration) var(--ease),
+ box-shadow var(--duration) var(--ease);
+}
+.panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: var(--radius-lg);
+ padding: 1px;
+ background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--duration) var(--ease);
+}
+.panel:hover::before { opacity: 1; }
+.panel:hover {
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
+}
+
+/* ── Stat cards: gradient surface + shimmer top edge ─────── */
+.stat-card {
+ position: relative;
+ background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
+ overflow: hidden;
+}
+.stat-card::after {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
+ pointer-events: none;
+}
+.stat-card:hover {
+ box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
+ transform: translateY(-2px);
+}
+
+/* ── Buttons: gradient fills + inner highlight ───────────── */
+.btn { position: relative; overflow: hidden; }
+.btn::after {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 50%;
+ background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
+ border-radius: inherit;
+ pointer-events: none;
+}
+.btn-primary {
+ background: linear-gradient(135deg, var(--accent), #4a8de0);
+ box-shadow: 0 2px 8px rgba(91,158,240,0.25);
+}
+.btn-primary:hover {
+ background: linear-gradient(135deg, var(--accent-hover), var(--accent));
+ box-shadow: 0 4px 20px rgba(91,158,240,0.4);
+}
+.btn-success {
+ background: linear-gradient(135deg, var(--success), #38c46c);
+ box-shadow: 0 2px 8px rgba(74,222,128,0.2);
+}
+.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
+.btn-purple {
+ background: linear-gradient(135deg, var(--purple), #9171f0);
+ box-shadow: 0 2px 8px rgba(167,139,250,0.2);
+}
+.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
+
+/* ── Tags: subtle colored border ────────────────────────── */
+.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
+.tag-green { border: 1px solid rgba(74,222,128,0.25); }
+.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
+.tag-red { border: 1px solid rgba(248,113,113,0.25); }
+.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
+
+/* ── Progress fill: glow ─────────────────────────────────── */
+.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
+
+/* ── Badge running: glow dot ─────────────────────────────── */
+.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
+.badge-running::before { box-shadow: 0 0 6px var(--accent); }
+
+/* ── Toast: glassmorphism + colored glow ─────────────────── */
+.toast { backdrop-filter: blur(12px); }
+.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
+.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
+
+/* ── Divider: fade-out gradient ──────────────────────────── */
+.divider {
+ background: linear-gradient(90deg, transparent, var(--border), transparent);
+}
+
+/* ── Inputs: inset shadow for depth ─────────────────────── */
+.form-input, .form-select, .form-textarea {
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
+}
+.form-input:focus, .form-select:focus, .form-textarea:focus {
+ box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
+}
+
+
+/* ══════════════════════════════════════════════════════════════
+ Tooltip System (.tip-icon + data-tip)
+ Shared across all pages — do not duplicate in page-specific CSS
+ ══════════════════════════════════════════════════════════════ */
+
+.tip-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ background: var(--surface-3);
+ color: var(--text-secondary);
+ font-size: 10px;
+ font-weight: 700;
+ font-style: normal;
+ cursor: default;
+ position: relative;
+ vertical-align: middle;
+ margin-left: 4px;
+ flex-shrink: 0;
+ transition: background 0.15s, color 0.15s;
+ user-select: none;
+}
+
+.tip-icon:hover {
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+
+.tip-icon::after {
+ content: attr(data-tip);
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--surface-0, #1a1a2e);
+ color: var(--text, #e0e0e0);
+ border: 1px solid var(--border, #333);
+ border-radius: var(--radius-md, 6px);
+ padding: 8px 12px;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1.5;
+ white-space: normal;
+ width: 240px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 1000;
+ text-align: left;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.tip-icon::before {
+ content: '';
+ position: absolute;
+ bottom: calc(100% + 3px);
+ left: 50%;
+ transform: translateX(-50%);
+ border: 5px solid transparent;
+ border-top-color: var(--border, #333);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 1001;
+}
+
+.tip-icon:hover::after,
+.tip-icon:hover::before {
+ opacity: 1;
+}
+
+.tip-icon.tip-below::after {
+ bottom: auto;
+ top: calc(100% + 8px);
+}
+
+.tip-icon.tip-below::before {
+ bottom: auto;
+ top: calc(100% + 3px);
+ border-top-color: transparent;
+ border-bottom-color: var(--border, #333);
+}
diff --git a/lan-client/index.html b/lan-client/index.html
index 6460f15..18f1167 100644
--- a/lan-client/index.html
+++ b/lan-client/index.html
@@ -51,6 +51,9 @@
SBA
⚙
🔍
+ 🃏
+ ⚡
+ 📚
@@ -688,10 +691,122 @@ document.addEventListener('DOMContentLoaded', function() {
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
+
+ // Wire up WebSocket push events from backend
+ AIBridge.connector.on('ai:decision', function(msg) {
+ if (!gameState || !msg.action) return;
+ var playerIdx = gameState.currentPlayerIndex;
+ var player = gameState.players[playerIdx];
+ if (player && player.isAI) {
+ AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
+ }
+ });
+
+ AIBridge.connector.on('state:snapshot', function(msg) {
+ if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
+ console.log('[WS] State snapshot received from backend');
+ }
+ });
});
}
});
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+