feat: complete M3 Web UI Enhancement milestone with include/exclude cards, fuzzy matching, mobile responsive design, and performance optimization

- Include/exclude cards feature complete with 300+ card knowledge base and intelligent fuzzy matching
- Enhanced visual validation with warning icons and performance benchmarks (100% pass rate)
- Mobile responsive design with bottom-floating controls, two-column layout, and horizontal scroll prevention
- Dark theme confirmation modal for fuzzy matches with card preview and alternatives
- Dual architecture support for web UI staging system and CLI direct build paths
- All M3 checklist items completed: fuzzy match modal, enhanced algorithm, summary panel, mobile responsive, Playwright tests
This commit is contained in:
matt 2025-09-09 18:15:30 -07:00
parent 0516260304
commit cfcc01db85
37 changed files with 3837 additions and 162 deletions

View file

@ -440,7 +440,11 @@ async def build_new_submit(
multi_count: int | None = Form(None),
multi_thrumming: str | None = Form(None),
# Must-haves/excludes (optional)
include_cards: str = Form(""),
exclude_cards: str = Form(""),
enforcement_mode: str = Form("warn"),
allow_illegal: bool = Form(False),
fuzzy_matching: bool = Form(True),
) -> HTMLResponse:
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
sid = request.cookies.get("sid") or new_sid()
@ -467,7 +471,11 @@ async def build_new_submit(
"combo_count": combo_count,
"combo_balance": (combo_balance or "mix"),
"prefer_combos": bool(prefer_combos),
"include_cards": include_cards or "",
"exclude_cards": exclude_cards or "",
"enforcement_mode": enforcement_mode or "warn",
"allow_illegal": bool(allow_illegal),
"fuzzy_matching": bool(fuzzy_matching),
}
}
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
@ -575,37 +583,59 @@ async def build_new_submit(
except Exception:
pass
# Process exclude cards (M0.5: Phase 1 - Exclude Only)
# Process include/exclude cards (M3: Phase 2 - Full Include/Exclude)
try:
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
# Clear any old exclude data
for k in ["exclude_cards", "exclude_diagnostics"]:
# Clear any old include/exclude data
for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]:
if k in sess:
del sess[k]
# Process include cards
if include_cards and include_cards.strip():
print(f"DEBUG: Raw include_cards input: '{include_cards}'")
include_list = parse_card_list_input(include_cards.strip())
print(f"DEBUG: Parsed include_list: {include_list}")
sess["include_cards"] = include_list
else:
print(f"DEBUG: include_cards is empty or None: '{include_cards}'")
# Process exclude cards
if exclude_cards and exclude_cards.strip():
# Parse the exclude list
print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'")
exclude_list = parse_card_list_input(exclude_cards.strip())
# Store in session for the build engine
print(f"DEBUG: Parsed exclude_list: {exclude_list}")
sess["exclude_cards"] = exclude_list
# Create diagnostics (for future status display)
else:
print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'")
# Store advanced options
sess["enforcement_mode"] = enforcement_mode
sess["allow_illegal"] = allow_illegal
sess["fuzzy_matching"] = fuzzy_matching
# Create basic diagnostics for status tracking
if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()):
diagnostics = IncludeExcludeDiagnostics(
missing_includes=[],
ignored_color_identity=[],
illegal_dropped=[],
illegal_allowed=[],
excluded_removed=exclude_list,
excluded_removed=sess.get("exclude_cards", []),
duplicates_collapsed={},
include_added=[],
include_over_ideal={},
fuzzy_corrections={},
confirmation_needed=[],
list_size_warnings={"excludes_count": len(exclude_list), "excludes_limit": 15}
list_size_warnings={
"includes_count": len(sess.get("include_cards", [])),
"excludes_count": len(sess.get("exclude_cards", [])),
"includes_limit": 10,
"excludes_limit": 15
}
)
sess["exclude_diagnostics"] = diagnostics.__dict__
sess["include_exclude_diagnostics"] = diagnostics.__dict__
except Exception as e:
# If exclude parsing fails, log but don't block the build
import logging
@ -2570,9 +2600,18 @@ async def build_permalink(request: Request):
"locks": list(sess.get("locks", [])),
}
# Add exclude_cards if feature is enabled and present
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
payload["exclude_cards"] = sess.get("exclude_cards")
# Add include/exclude cards and advanced options if feature is enabled
if ALLOW_MUST_HAVES:
if sess.get("include_cards"):
payload["include_cards"] = sess.get("include_cards")
if sess.get("exclude_cards"):
payload["exclude_cards"] = sess.get("exclude_cards")
if sess.get("enforcement_mode"):
payload["enforcement_mode"] = sess.get("enforcement_mode")
if sess.get("allow_illegal") is not None:
payload["allow_illegal"] = sess.get("allow_illegal")
if sess.get("fuzzy_matching") is not None:
payload["fuzzy_matching"] = sess.get("fuzzy_matching")
try:
import base64
import json as _json
@ -2638,32 +2677,248 @@ async def validate_exclude_cards(
exclude_cards: str = Form(default=""),
commander: str = Form(default="")
):
"""Validate exclude cards list and return diagnostics."""
"""Legacy exclude cards validation endpoint - redirect to new unified endpoint."""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
# Call new unified endpoint
result = await validate_include_exclude_cards(
request=request,
include_cards="",
exclude_cards=exclude_cards,
commander=commander,
enforcement_mode="warn",
allow_illegal=False,
fuzzy_matching=True
)
# Transform to legacy format for backward compatibility
if hasattr(result, 'body'):
import json
data = json.loads(result.body)
if 'excludes' in data:
excludes = data['excludes']
return JSONResponse({
"count": excludes.get("count", 0),
"limit": excludes.get("limit", 15),
"over_limit": excludes.get("over_limit", False),
"cards": excludes.get("cards", []),
"duplicates": excludes.get("duplicates", {}),
"warnings": excludes.get("warnings", [])
})
return result
@router.post("/validate/include_exclude")
async def validate_include_exclude_cards(
request: Request,
include_cards: str = Form(default=""),
exclude_cards: str = Form(default=""),
commander: str = Form(default=""),
enforcement_mode: str = Form(default="warn"),
allow_illegal: bool = Form(default=False),
fuzzy_matching: bool = Form(default=True)
):
"""Validate include/exclude card lists with comprehensive diagnostics."""
if not ALLOW_MUST_HAVES:
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
try:
from deck_builder.include_exclude_utils import parse_card_list_input
from deck_builder.include_exclude_utils import (
parse_card_list_input, collapse_duplicates,
fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES
)
from deck_builder.builder import DeckBuilder
# Parse the input
card_list = parse_card_list_input(exclude_cards)
# Parse inputs
include_list = parse_card_list_input(include_cards) if include_cards.strip() else []
exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else []
# Basic validation
total_count = len(card_list)
max_excludes = 15
# Collapse duplicates
include_unique, include_dupes = collapse_duplicates(include_list)
exclude_unique, exclude_dupes = collapse_duplicates(exclude_list)
# For now, just return count and limit info
# Future: add fuzzy matching validation, commander color identity checks
# Initialize result structure
result = {
"count": total_count,
"limit": max_excludes,
"over_limit": total_count > max_excludes,
"cards": card_list[:10] if len(card_list) <= 10 else card_list[:7] + ["..."], # Show preview
"warnings": []
"includes": {
"count": len(include_unique),
"limit": MAX_INCLUDES,
"over_limit": len(include_unique) > MAX_INCLUDES,
"duplicates": include_dupes,
"cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."],
"warnings": [],
"legal": [],
"illegal": [],
"color_mismatched": [],
"fuzzy_matches": {}
},
"excludes": {
"count": len(exclude_unique),
"limit": MAX_EXCLUDES,
"over_limit": len(exclude_unique) > MAX_EXCLUDES,
"duplicates": exclude_dupes,
"cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."],
"warnings": [],
"legal": [],
"illegal": [],
"fuzzy_matches": {}
},
"conflicts": [], # Cards that appear in both lists
"confirmation_needed": [], # Cards needing fuzzy match confirmation
"overall_warnings": []
}
if total_count > max_excludes:
result["warnings"].append(f"Too many excludes: {total_count}/{max_excludes}")
# Check for conflicts (cards in both lists)
conflicts = set(include_unique) & set(exclude_unique)
if conflicts:
result["conflicts"] = list(conflicts)
result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}")
# Size warnings based on actual counts
if result["includes"]["over_limit"]:
result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}")
elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning
result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}")
if result["excludes"]["over_limit"]:
result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}")
elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning
result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}")
# Do fuzzy matching regardless of commander (for basic card validation)
if fuzzy_matching and (include_unique or exclude_unique):
print(f"DEBUG: Attempting fuzzy matching with {len(include_unique)} includes, {len(exclude_unique)} excludes")
try:
# Get card names directly from CSV without requiring commander setup
import pandas as pd
cards_df = pd.read_csv('csv_files/cards.csv')
print(f"DEBUG: CSV columns: {list(cards_df.columns)}")
# Try to find the name column
name_column = None
for col in ['Name', 'name', 'card_name', 'CardName']:
if col in cards_df.columns:
name_column = col
break
if name_column is None:
raise ValueError(f"Could not find name column. Available columns: {list(cards_df.columns)}")
available_cards = set(cards_df[name_column].tolist())
print(f"DEBUG: Loaded {len(available_cards)} available cards")
# Validate includes with fuzzy matching
for card_name in include_unique:
print(f"DEBUG: Testing include card: {card_name}")
match_result = fuzzy_match_card_name(card_name, available_cards)
print(f"DEBUG: Match result - name: {match_result.matched_name}, auto_accepted: {match_result.auto_accepted}, confidence: {match_result.confidence}")
if match_result.matched_name and match_result.auto_accepted:
# Exact or high-confidence match
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["includes"]["legal"].append(match_result.matched_name)
elif not match_result.auto_accepted and match_result.suggestions:
# Needs confirmation - has suggestions but low confidence
print(f"DEBUG: Adding confirmation for {card_name}")
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "include"
})
else:
# No match found at all, add to illegal
result["includes"]["illegal"].append(card_name)
# Validate excludes with fuzzy matching
for card_name in exclude_unique:
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["excludes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "exclude"
})
else:
# No match found, add to illegal
result["excludes"]["illegal"].append(card_name)
except Exception as fuzzy_error:
print(f"DEBUG: Fuzzy matching error: {str(fuzzy_error)}")
import traceback
traceback.print_exc()
result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}")
# If we have a commander, do advanced validation (color identity, etc.)
if commander and commander.strip():
try:
# Create a temporary builder to get available card names
builder = DeckBuilder()
builder.setup_dataframes()
# Get available card names for fuzzy matching
available_cards = set(builder._full_cards_df['Name'].tolist())
# Validate includes with fuzzy matching
for card_name in include_unique:
if fuzzy_matching:
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["includes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "include"
})
else:
result["includes"]["illegal"].append(card_name)
else:
# Exact match only
if card_name in available_cards:
result["includes"]["legal"].append(card_name)
else:
result["includes"]["illegal"].append(card_name)
# Validate excludes with fuzzy matching
for card_name in exclude_unique:
if fuzzy_matching:
match_result = fuzzy_match_card_name(card_name, available_cards)
if match_result.matched_name:
if match_result.auto_accepted:
result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name
result["excludes"]["legal"].append(match_result.matched_name)
else:
# Needs confirmation
result["confirmation_needed"].append({
"input": card_name,
"suggestions": match_result.suggestions,
"confidence": match_result.confidence,
"type": "exclude"
})
else:
result["excludes"]["illegal"].append(card_name)
else:
# Exact match only
if card_name in available_cards:
result["excludes"]["legal"].append(card_name)
else:
result["excludes"]["illegal"].append(card_name)
except Exception as validation_error:
# Advanced validation failed, but return basic validation
result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}")
return JSONResponse(result)

View file

@ -83,6 +83,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
prefer_combos=bool(sess.get("prefer_combos")),
combo_target_count=int(sess.get("combo_target_count", 2)),
combo_balance=str(sess.get("combo_balance", "mix")),
include_cards=sess.get("include_cards"),
exclude_cards=sess.get("exclude_cards"),
)
if set_on_session:

View file

@ -1030,6 +1030,23 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception as e:
out(f"Land build failed: {e}")
# M3: Inject includes after lands, before creatures/spells (matching CLI behavior)
try:
if hasattr(b, '_inject_includes_after_lands'):
print(f"DEBUG WEB: About to inject includes. Include cards: {getattr(b, 'include_cards', [])}")
# Use builder's logger if available
if hasattr(b, 'logger'):
b.logger.info(f"DEBUG WEB: About to inject includes. Include cards: {getattr(b, 'include_cards', [])}")
b._inject_includes_after_lands()
print(f"DEBUG WEB: Finished injecting includes. Current deck size: {len(getattr(b, 'card_library', {}))}")
if hasattr(b, 'logger'):
b.logger.info(f"DEBUG WEB: Finished injecting includes. Current deck size: {len(getattr(b, 'card_library', {}))}")
except Exception as e:
out(f"Include injection failed: {e}")
print(f"Include injection failed: {e}")
if hasattr(b, 'logger'):
b.logger.error(f"Include injection failed: {e}")
try:
if hasattr(b, 'add_creatures_phase'):
b.add_creatures_phase()
@ -1285,6 +1302,10 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
fn = getattr(b, f"run_land_step{i}", None)
if callable(fn):
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
# M3: Include injection stage after lands, before creatures
if hasattr(b, '_inject_includes_after_lands') and getattr(b, 'include_cards', None):
stages.append({"key": "inject_includes", "label": "Include Cards", "runner_name": "__inject_includes__"})
# Creatures split into theme sub-stages for web confirm
# AND-mode pre-pass: add cards that match ALL selected themes first
try:
@ -1377,6 +1398,7 @@ def start_build_ctx(
prefer_combos: bool | None = None,
combo_target_count: int | None = None,
combo_balance: str | None = None,
include_cards: List[str] | None = None,
exclude_cards: List[str] | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
@ -1451,8 +1473,17 @@ def start_build_ctx(
# Apply the same global pool pruning in interactive builds for consistency
_global_prune_disallowed_pool(b)
# Apply exclude cards (M0.5: Phase 1 - Exclude Only)
# Apply include/exclude cards (M3: Phase 2 - Full Include/Exclude)
try:
out(f"DEBUG ORCHESTRATOR: include_cards parameter: {include_cards}")
out(f"DEBUG ORCHESTRATOR: exclude_cards parameter: {exclude_cards}")
if include_cards:
b.include_cards = list(include_cards)
out(f"Applied include cards: {len(include_cards)} cards")
out(f"DEBUG ORCHESTRATOR: Set builder.include_cards to: {b.include_cards}")
else:
out("DEBUG ORCHESTRATOR: No include cards to apply")
if exclude_cards:
b.exclude_cards = list(exclude_cards)
# The filtering is already applied in setup_dataframes(), but we need
@ -1460,8 +1491,10 @@ def start_build_ctx(
b._combined_cards_df = None # Clear cache to force rebuild
b.setup_dataframes() # This will now apply the exclude filtering
out(f"Applied exclude filtering for {len(exclude_cards)} patterns")
else:
out("DEBUG ORCHESTRATOR: No exclude cards to apply")
except Exception as e:
out(f"Failed to apply exclude cards: {e}")
out(f"Failed to apply include/exclude cards: {e}")
# Thread multi-copy selection onto builder for stage generation/runner
try:
@ -1874,6 +1907,18 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
logs.append("No multi-copy additions (empty selection).")
except Exception as e:
logs.append(f"Stage '{label}' failed: {e}")
elif runner_name == '__inject_includes__':
try:
if hasattr(b, '_inject_includes_after_lands'):
b._inject_includes_after_lands()
include_count = len(getattr(b, 'include_cards', []))
logs.append(f"Include injection completed: {include_count} cards processed")
else:
logs.append("Include injection method not available")
except Exception as e:
logs.append(f"Include injection failed: {e}")
if hasattr(b, 'logger'):
b.logger.error(f"Include injection failed: {e}")
elif runner_name == '__auto_complete_combos__':
try:
# Load curated combos

View file

@ -65,7 +65,7 @@
--blue-main: #1565c0; /* balanced blue */
}
*{box-sizing:border-box}
html,body{height:100%}
html,body{height:100%; overflow-x:hidden; max-width:100vw;}
body {
font-family: system-ui, Arial, sans-serif;
margin: 0;
@ -74,6 +74,7 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
@ -84,7 +85,7 @@ body {
.top-banner{ min-height: var(--banner-h); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
.banner-status.busy{ color:#fbbf24; }
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
@ -125,7 +126,14 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
.sidebar{ transform: translateX(-100%); visibility: hidden; }
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
.content{ padding: .9rem .8rem; }
.content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
}
/* Additional mobile spacing for bottom floating controls */
@media (max-width: 720px) {
.content {
padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */
}
}
.brand h1{ display:none; }
@ -290,10 +298,36 @@ small, .muted{ color: var(--muted); }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks */
.build-controls { top: calc(var(--banner-offset, 48px) + 6px); }
@media (max-width: 720px){
.build-controls {
position: sticky;
top: calc(var(--banner-offset, 48px) + 6px);
z-index: 100;
background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 10px;
margin: 0.5rem 0;
box-shadow: 0 4px 12px rgba(0,0,0,.25);
}
@media (max-width: 1024px){
:root { --banner-offset: 56px; }
.build-controls { position: sticky; border-radius: 8px; margin-left: 0; margin-right: 0; }
.build-controls {
position: fixed !important; /* Fixed to viewport instead of sticky */
bottom: 0 !important; /* Anchor to bottom of screen */
left: 0 !important;
right: 0 !important;
top: auto !important; /* Override top positioning */
border-radius: 0 !important; /* Remove border radius for full width */
margin: 0 !important; /* Remove margins for full edge-to-edge */
padding: 0.5rem !important; /* Reduced padding */
box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */
border-left: none !important;
border-right: none !important;
border-bottom: none !important; /* Remove bottom border */
background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important;
z-index: 1000 !important; /* Higher z-index to ensure it's above content */
}
}
@media (min-width: 721px){
:root { --banner-offset: 48px; }
@ -347,3 +381,128 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
/* Virtualization wrapper should mirror grid to keep multi-column flow */
.virt-wrapper { display: grid; }
/* Mobile responsive fixes for horizontal scrolling issues */
@media (max-width: 768px) {
/* Prevent horizontal overflow */
html, body {
overflow-x: hidden !important;
width: 100% !important;
max-width: 100vw !important;
}
/* Fix modal layout on mobile */
.modal {
padding: 10px !important;
box-sizing: border-box;
}
.modal-content {
width: 100% !important;
max-width: calc(100vw - 20px) !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
/* Force single column for include/exclude grid */
.include-exclude-grid {
display: flex !important;
flex-direction: column !important;
gap: 1rem !important;
}
/* Fix basics grid */
.basics-grid {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
/* Ensure all inputs and textareas fit properly */
.modal input,
.modal textarea,
.modal select {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
min-width: 0 !important;
}
/* Fix chips containers */
.modal [id$="_chips_container"] {
max-width: 100% !important;
overflow-x: hidden !important;
word-wrap: break-word !important;
}
/* Ensure fieldsets don't overflow */
.modal fieldset {
max-width: 100% !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
/* Fix any inline styles that might cause overflow */
.modal fieldset > div,
.modal fieldset > div > div {
max-width: 100% !important;
overflow-x: hidden !important;
}
}
@media (max-width: 480px) {
.modal-content {
padding: 12px !important;
margin: 5px !important;
}
.modal fieldset {
padding: 8px !important;
margin: 6px 0 !important;
}
/* Enhanced mobile build controls */
.build-controls {
flex-direction: column !important;
gap: 0.25rem !important; /* Reduced gap */
align-items: stretch !important;
padding: 0.5rem !important; /* Reduced padding */
}
/* Two-column grid layout for mobile build controls */
.build-controls {
display: grid !important;
grid-template-columns: 1fr 1fr !important; /* Two equal columns */
grid-gap: 0.25rem !important;
align-items: stretch !important;
}
.build-controls form {
display: contents !important; /* Allow form contents to participate in grid */
width: auto !important;
}
.build-controls button {
flex: none !important;
padding: 0.4rem 0.5rem !important; /* Much smaller padding */
font-size: 12px !important; /* Smaller font */
min-height: 36px !important; /* Smaller minimum height */
line-height: 1.2 !important;
width: 100% !important; /* Full width within grid cell */
box-sizing: border-box !important;
white-space: nowrap !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Hide non-essential elements on mobile to keep it clean */
.build-controls .sep,
.build-controls .replace-toggle,
.build-controls label[style*="margin-left"] {
display: none !important;
}
.build-controls .sep {
display: none !important; /* Hide separators on mobile */
}
}

View file

@ -178,8 +178,10 @@
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
el.classList.add('busy');
} else if (data && data.phase === 'done') {
el.innerHTML = '<span class="muted">Setup complete.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
// Don't show "Setup complete" message to avoid UI stuttering
// Just clear any existing content and remove busy state
el.innerHTML = '';
el.classList.remove('busy');
} else if (data && data.phase === 'error') {
el.innerHTML = '<span class="error">Setup error.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);

File diff suppressed because it is too large Load diff

View file

@ -313,6 +313,9 @@
<!-- controls now above -->
{% if status and status.startswith('Build complete') and summary %}
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
{% include "partials/include_exclude_summary.html" %}
{% include "partials/deck_summary.html" %}
{% endif %}
</div>

View file

@ -37,6 +37,8 @@
{% else %}
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
{% include "partials/include_exclude_summary.html" %}
{% if summary %}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}

View file

@ -0,0 +1,195 @@
{% if summary and summary.include_exclude_summary %}
{% set ie_summary = summary.include_exclude_summary %}
{% set has_data = (ie_summary.include_cards|length > 0) or (ie_summary.exclude_cards|length > 0) or (ie_summary.include_added|length > 0) or (ie_summary.excluded_removed|length > 0) %}
{% if has_data %}
<section style="margin-top:1rem;">
<h5>Include/Exclude Impact</h5>
<div style="margin:.5rem 0;">
<!-- Include Cards Impact -->
{% if ie_summary.include_cards|length > 0 %}
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115; margin-bottom:.75rem;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#4ade80;">
✓ Must Include Cards ({{ ie_summary.include_cards|length }})
</div>
<!-- Successfully added includes -->
{% if ie_summary.include_added|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#10b981; margin-bottom:.25rem;">
✓ Successfully Included ({{ ie_summary.include_added|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.include_added %}
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Missing includes -->
{% if ie_summary.missing_includes|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#ef4444; margin-bottom:.25rem;">
⚠ Could Not Include ({{ ie_summary.missing_includes|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.missing_includes %}
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Fuzzy corrections for includes -->
{% if ie_summary.fuzzy_corrections %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
⚡ Fuzzy Matched
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for original, corrected in ie_summary.fuzzy_corrections.items() %}
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" title="Original: {{ original }}">
{{ original }} → {{ corrected }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Exclude Cards Impact -->
{% if ie_summary.exclude_cards|length > 0 %}
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115; margin-bottom:.75rem;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#ef4444;">
✗ Must Exclude Cards ({{ ie_summary.exclude_cards|length }})
</div>
<!-- Successfully excluded cards -->
{% if ie_summary.excluded_removed|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#10b981; margin-bottom:.25rem;">
✓ Successfully Excluded ({{ ie_summary.excluded_removed|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.excluded_removed %}
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Show patterns for reference -->
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; margin-bottom:.25rem;">
Exclude Patterns
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for pattern in ie_summary.exclude_cards %}
<span class="chip" style="background:#374151; color:#e5e7eb; border:1px solid #4b5563;">{{ pattern }}</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Validation Issues -->
{% set has_issues = (ie_summary.illegal_dropped|length > 0) or (ie_summary.illegal_allowed|length > 0) or (ie_summary.ignored_color_identity|length > 0) or (ie_summary.duplicates_collapsed|length > 0) %}
{% if has_issues %}
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600; color:#f59e0b;">
⚠ Validation Issues
</div>
<!-- Illegal cards dropped -->
{% if ie_summary.illegal_dropped|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#ef4444; margin-bottom:.25rem;">
Illegal Cards Dropped ({{ ie_summary.illegal_dropped|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.illegal_dropped %}
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Illegal cards allowed -->
{% if ie_summary.illegal_allowed|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
Illegal Cards Allowed ({{ ie_summary.illegal_allowed|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.illegal_allowed %}
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Color identity issues -->
{% if ie_summary.ignored_color_identity|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#f59e0b; margin-bottom:.25rem;">
Color Identity Mismatches ({{ ie_summary.ignored_color_identity|length }})
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in ie_summary.ignored_color_identity %}
<span class="chip" style="background:#fef3c7; color:#92400e; border:1px solid #fde68a;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Duplicate collapses -->
{% if ie_summary.duplicates_collapsed|length > 0 %}
<div style="margin:.25rem 0;">
<div class="muted" style="font-size:12px; color:#6366f1; margin-bottom:.25rem;">
Duplicates Collapsed ({{ ie_summary.duplicates_collapsed|length }} groups)
</div>
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card, count in ie_summary.duplicates_collapsed.items() %}
<span class="chip" style="background:#e0e7ff; color:#4338ca; border:1px solid #c7d2fe;" data-card-name="{{ card }}">
{{ card }} ({{ count }}x)
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</section>
<!-- Mobile responsive styles for include/exclude summary (M3: Mobile Responsive Testing) -->
<style>
@media (max-width: 768px) {
.impact-panel {
padding: .5rem !important;
}
.ie-chips {
gap: .25rem !important;
}
.ie-chips .chip {
font-size: 12px !important;
padding: 2px 6px !important;
word-break: break-word;
}
}
@media (max-width: 480px) {
.impact-panel {
padding: .4rem !important;
}
.ie-chips .chip {
font-size: 11px !important;
padding: 1px 4px !important;
}
}
</style>
{% endif %}
{% endif %}