feat: add ideal counts slider UI with smart validation

This commit is contained in:
matt 2025-10-14 16:45:49 -07:00
parent 9ab3835e2a
commit 35bff901d2
12 changed files with 217 additions and 11 deletions

View file

@ -128,6 +128,7 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider'
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)

View file

@ -13,6 +13,7 @@ from ..app import (
_sanitize_theme,
ENABLE_PARTNER_MECHANICS,
ENABLE_PARTNER_SUGGESTIONS,
WEB_IDEALS_UI,
)
from ..services.build_utils import (
step5_base_ctx,
@ -1356,6 +1357,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": {
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"),
@ -1364,6 +1366,15 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"use_owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
# Add ideal values from session (will be None on first load, triggering defaults)
"ramp": sess.get("ideals", {}).get("ramp"),
"lands": sess.get("ideals", {}).get("lands"),
"basic_lands": sess.get("ideals", {}).get("basic_lands"),
"creatures": sess.get("ideals", {}).get("creatures"),
"removal": sess.get("ideals", {}).get("removal"),
"wipes": sess.get("ideals", {}).get("wipes"),
"card_advantage": sess.get("ideals", {}).get("card_advantage"),
"protection": sess.get("ideals", {}).get("protection"),
},
"tag_slot_html": None,
}

View file

@ -678,3 +678,52 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Ideals Slider Styling */
.ideals-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
background: var(--border);
border-radius: 3px;
outline: none;
}
.ideals-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--ring);
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
}
.ideals-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
}
.ideals-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--ring);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
}
.ideals-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
}
.slider-value {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
}

View file

@ -0,0 +1,124 @@
<fieldset>
<legend>Ideal Counts</legend>
<div class="muted" style="font-size:12px; margin-bottom:0.75rem;">
Sliders start at recommended defaults. Adjust as needed for your deck strategy.
<span id="ideals-validation-warning" style="color:#f59e0b; display:none; margin-left:0.5rem;"></span>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:1rem;">
{% set use_sliders = ideals_ui_mode == 'slider' %}
{% set ideals_data = [
('ramp', labels.ramp, defaults.ramp, 0, 30),
('lands', labels.lands, defaults.lands, 25, 45),
('basic_lands', labels.basic_lands, defaults.basic_lands, 0, 40),
('creatures', labels.creatures, defaults.creatures, 0, 70),
('removal', labels.removal, defaults.removal, 0, 30),
('wipes', labels.wipes, defaults.wipes, 0, 15),
('card_advantage', labels.card_advantage, defaults.card_advantage, 0, 30),
('protection', labels.protection, defaults.protection, 0, 20)
] %}
{% for field_name, field_label, default_val, min_val, max_val in ideals_data %}
<div>
<label style="display:block; margin-bottom:0.25rem;">
<span>{{ field_label }}</span>
{% if use_sliders %}
<div style="display:flex; align-items:center; gap:0.5rem; margin-top:0.25rem;">
<input
type="range"
name="{{ field_name }}"
min="{{ min_val }}"
max="{{ max_val }}"
value="{{ form[field_name] if form and form[field_name] is not none else default_val }}"
class="ideals-slider"
data-field="{{ field_name }}"
style="flex:1; cursor:pointer;"
/>
<output
id="{{ field_name }}_value"
class="slider-value"
style="min-width:2.5rem; text-align:center; font-weight:600; color:var(--accent); font-size:14px;"
>
{{ form[field_name] if form and form[field_name] is not none else default_val }}
</output>
</div>
{% else %}
<input
type="number"
name="{{ field_name }}"
min="{{ min_val }}"
max="100"
value="{{ form[field_name] if form and form[field_name] is not none else '' }}"
placeholder="{{ default_val }} (Default)"
style="width:100%; margin-top:0.25rem;"
/>
{% endif %}
</label>
</div>
{% endfor %}
</div>
<script>
// Validate ideal counts to prevent over-allocation
function validateIdealCounts() {
const warning = document.getElementById('ideals-validation-warning');
if (!warning) return;
// Get all ideal values
const getValue = (name) => {
const input = document.querySelector('[name="' + name + '"]');
return input ? parseInt(input.value) || 0 : 0;
};
const lands = getValue('lands');
const creatures = getValue('creatures');
const ramp = getValue('ramp');
const removal = getValue('removal');
const wipes = getValue('wipes');
const cardAdvantage = getValue('card_advantage');
const protection = getValue('protection');
// Calculate estimated total with overlap factor
// lands + creatures + (spells / 2) since spells often overlap with creatures
const spells = ramp + removal + wipes + cardAdvantage + protection;
const estimatedTotal = lands + creatures + Math.ceil(spells / 2);
// Check if estimated total exceeds deck size (99 for EDH)
if (estimatedTotal > 99) {
warning.textContent = '⚠ Estimated total ~' + estimatedTotal + ' cards (max 99). Reduce counts to avoid build issues.';
warning.style.display = 'inline';
warning.style.color = '#ef4444'; // Red for hard conflict
} else if (estimatedTotal > 90) {
warning.textContent = '⚠ Estimated total ~' + estimatedTotal + ' cards. Deck may be tight on slots.';
warning.style.display = 'inline';
warning.style.color = '#f59e0b'; // Orange for warning
} else {
warning.style.display = 'none';
}
}
// Attach validation to all ideal inputs
{% if use_sliders %}
document.querySelectorAll('.ideals-slider').forEach(slider => {
const fieldName = slider.dataset.field;
const output = document.getElementById(fieldName + '_value');
// Update display on input
slider.addEventListener('input', function() {
output.textContent = this.value;
validateIdealCounts();
});
// Ensure initial value is displayed
output.textContent = slider.value;
});
{% else %}
document.querySelectorAll('[name="ramp"], [name="lands"], [name="basic_lands"], [name="creatures"], [name="removal"], [name="wipes"], [name="card_advantage"], [name="protection"]').forEach(input => {
input.addEventListener('input', validateIdealCounts);
input.addEventListener('change', validateIdealCounts);
});
{% endif %}
// Run initial validation
validateIdealCounts();
</script>
</fieldset>

View file

@ -110,6 +110,7 @@
</div>
</div>
</fieldset>
{% include "build/_new_deck_ideals.html" %}
{% if allow_must_haves %}
<fieldset>
<legend>Include/Exclude Cards</legend>
@ -213,16 +214,6 @@
{% endif %}
{% endif %}
{% include "build/_new_deck_skip_controls.html" %}
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
{% for key, label in labels.items() %}
<label>{{ label }}
<input type="number" name="{{ key }}" value="{{ defaults[key] }}" min="0" />
</label>
{% endfor %}
</div>
</details>
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<div style="display:flex; gap:.5rem;">