Merge pull request #9 from mwisnowski/maintenance/mobile-ui

This commit is contained in:
mwisnowski 2025-09-02 16:04:46 -07:00 committed by GitHub
commit 42c8fc9f9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 408 additions and 60 deletions

View file

@ -13,12 +13,22 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
### Changed
### Fixed
## [2.2.4] - 2025-09-02
### Added
- Mobile: Collapsible left sidebar with persisted state; sticky build controls adjusted for mobile header.
- New Deck modal integrates Multi-Copy suggestions (opt-in) and commander/theme preview.
- Web: Setup/Refresh prompt modal shown on Create when environment is missing or stale; routes to `/setup/running` (force on stale) and transitions into the progress view. Template: `web/templates/build/_setup_prompt_modal.html`.
- Orchestrator helpers: `is_setup_ready()` and `is_setup_stale()` for non-invasive readiness/staleness checks from the UI.
- Env flags for setup behavior: `WEB_AUTO_SETUP` (default 1) to enable/disable auto setup, and `WEB_AUTO_REFRESH_DAYS` (default 7) to tune staleness.
- Step 5 error context helper: `web/services/build_utils.step5_error_ctx()` to standardize error payloads for `_step5.html`.
- Templates: reusable lock/unlock button macro at `web/templates/partials/_macros.html`.
- Templates: Alternatives panel partial at `web/templates/build/_alternatives.html` (renders candidates with Owned-only toggle and Replace actions).
- Step 5 error context helper: `web/services/build_utils.step5_error_ctx()` to standardize error payloads for `_step5.html`.
- Templates: reusable lock/unlock button macro at `web/templates/partials/_macros.html`.
- Templates: Alternatives panel partial at `web/templates/build/_alternatives.html` (renders candidates with Owned-only toggle and Replace actions).
### Tests
- Added smoke/unit tests covering:
@ -28,6 +38,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- `build_utils.step5_error_ctx()` shape and flags
### Changed
- Mobile UI scaling and layout fixed across steps; overlap in DevTools emulation resolved with CSS variable offsets for sticky elements.
- Multi-Copy is now explicitly opt-in from the New Deck modal; suggestions are filtered to only show archetypes whose matched tags intersect the user-selected themes (e.g., Rabbit Kindred shows only Hare Apparent).
- Web cleanup: centralized combos/synergies detection and model/version loading in `web/services/combo_utils.py` and refactored routes to use it:
- `routes/build.py` (Combos panel), `routes/configs.py` (run results), `routes/decks.py` (finished/compare), and diagnostics endpoint in `app.py`.
- Create (New Deck) flow: no longer auto-runs setup on submit; instead presents a modal prompt to run setup/refresh when needed.
@ -50,6 +62,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Build: Extended Step 5 error handling to Continue, Rerun, and Rewind using `step5_error_ctx()`.
### Fixed
- Continue button responsiveness on mobile fixed (eliminated sticky overlap); Multi-Copy application preserved across New Deck submit; emulator misclicks resolved.
- Banner subtitle now stays inline inside the header when the menu is collapsed (no overhang/wrap to a new row).
- Docker: normalized line endings for `entrypoint.sh` during image build to avoid `env: 'sh\r': No such file or directory` on Windows checkouts.
### Removed

View file

@ -1,40 +1,31 @@
# MTG Python Deckbuilder ${VERSION}
## Highlights
- Combos & Synergies: detect curated two-card combos and synergies, surface them in a unified chip-style panel on Step 5 and Finished Decks, and preview both cards on hover.
- Auto-Complete Combos: optional mode that adds missing partners up to a target before theme fill/monolithic spells so added pairs persist.
- Mobile UI polish: collapsible left sidebar with persisted state, sticky controls that respect the header, and banner subtitle that stays inline when the menu is collapsed.
- Multi-Copy is now opt-in from the New Deck modal, and suggestions are filtered to match selected themes (e.g., Rabbit Kindred → Hare Apparent).
- New Deck modal improvements: integrated commander preview, theme selection, and optional Multi-Copy in one flow.
## Whats new
- Detection: exact two-card combos and curated synergies with list version badges (combos.json/synergies.json).
- UI polish:
- Chip-style rows with compact badges (cheap/early, setup) in both the end-of-build panel and finished deck summary.
- Dual-card hover: moving your mouse over a combo row previews both cards side-by-side; hovering a single name shows that card alone.
- Ordering: when enabled, Auto-Complete Combos runs earlier (before theme fill and monolithic spells) to retain partners.
- Enforcement:
- Color identity respected via the filtered pool; off-color or unavailable partners are skipped gracefully.
- Honors Locks, Owned-only, and Replace toggles.
- Persistence & Headless parity:
- Interactive runs export these JSON fields and Web headless runs accept them:
- prefer_combos (bool)
- combo_target_count (int)
- combo_balance ("early" | "late" | "mix")
## JSON (Web Configs) — example
```json
{
"prefer_combos": true,
"combo_target_count": 3,
"combo_balance": "mix"
}
```
- Mobile & layout
- Sidebar toggle button (persisted in localStorage), smooth hide/show.
- Sticky build controls offset via CSS variables to avoid overlap in emulators and mobile.
- Banner subtitle stays within the header and remains inline with the title when the sidebar is collapsed.
- Multi-Copy
- Moved to Commander selection now instead of happening during building.
- Opt-in checkbox in the New Deck modal; disabled by default.
- Suggestions only appear when at least one theme is selected and are limited to archetypes whose matched tags intersect the themes.
- Multi-Copy runs first when selected, with an applied marker to avoid redundant rebuilds.
- New Deck & Setup
- Setup/Refresh prompt modal if the environment is missing or stale, with a clear path to run/refresh setup before building.
- Centralized staged context creation and error/render helpers for a more robust Step 5 flow.
## Notes
- Curated list versions are displayed in the UI for transparency.
- Existing completed pairs are counted toward the target; only missing partners are added.
- No changes to CLI inputs for this feature in this release.
- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON.
- Logic for removal tagging causing self-targetting cards (e.g. Conjurer's Closet) to be tagged as removal (2.2.3)
- Multi-Copy selection is part of the interactive New Deck modal (not a JSON field); it remains off unless explicitly enabled.
- Setup helpers: `is_setup_ready()` and `is_setup_stale()` inform the modal prompt and can be tuned with `WEB_AUTO_SETUP` and `WEB_AUTO_REFRESH_DAYS`.
- Headless parity: `tag_mode` (AND/OR) remains supported in JSON/env and exported in interactive run-config JSON.
## Fixes
- Fixed an issue with the Docker Hub image not having the config files for combos/synergies/default deck json example
- Bug causing basic lands to no longer be added due to combined dataframe not including basics (2.2.3)
- Continue responsiveness and click reliability on mobile/emulators; sticky overlap eliminated.
- Multi-Copy application preserved across New Deck submit; duplicate re-application avoided with an applied marker.
- Banner subtitle alignment fixed in collapsed-menu mode (no overhang, no line-wrap into a new row).
- Docker: normalized line endings for entrypoint to avoid Windows checkout issues.

View file

@ -332,6 +332,73 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
@router.get("/new/multicopy", response_class=HTMLResponse)
async def build_new_multicopy(
request: Request,
commander: str = Query(""),
primary_tag: str | None = Query(None),
secondary_tag: str | None = Query(None),
tertiary_tag: str | None = Query(None),
tag_mode: str | None = Query("AND"),
) -> HTMLResponse:
"""Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
This does not mutate the session; it simply renders a form snippet that posts with the main modal.
"""
name = (commander or "").strip()
if not name:
return HTMLResponse("")
try:
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
df = tmp.load_commander_data()
row = df[df["name"].astype(str) == name]
if row.empty:
return HTMLResponse("")
tmp._apply_commander_selection(row.iloc[0])
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
tmp.selected_tags = list(tags or [])
try:
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
except Exception:
pass
try:
tmp.determine_color_identity()
except Exception:
pass
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
# For the New Deck modal, only show suggestions where the matched tags intersect
# the explicitly selected tags (ignore commander-default themes).
sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()}
def _matched_reason_tags(item: dict) -> set[str]:
out = set()
try:
for r in item.get('reasons', []) or []:
if not isinstance(r, str):
continue
rl = r.strip().lower()
if rl.startswith('tags:'):
body = rl.split('tags:', 1)[1].strip()
parts = [p.strip() for p in body.split(',') if p.strip()]
out.update(parts)
except Exception:
return set()
return out
if sel_tags:
results = [it for it in results if (_matched_reason_tags(it) & sel_tags)]
else:
# If no selected tags, do not show any multi-copy suggestions in the modal
results = []
if not results:
return HTMLResponse("")
items = results[:5]
ctx = {"request": request, "items": items}
return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx)
except Exception:
return HTMLResponse("")
@router.post("/new", response_class=HTMLResponse)
async def build_new_submit(
request: Request,
@ -353,6 +420,11 @@ async def build_new_submit(
prefer_combos: bool = Form(False),
combo_count: int | None = Form(None),
combo_balance: str | None = Form(None),
enable_multicopy: bool = Form(False),
# Integrated Multi-Copy (optional)
multi_choice_id: str | None = Form(None),
multi_count: int | None = Form(None),
multi_thrumming: str | None = Form(None),
) -> HTMLResponse:
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
sid = request.cookies.get("sid") or new_sid()
@ -440,6 +512,39 @@ async def build_new_submit(
sess["combo_balance"] = bval
except Exception:
pass
# Multi-Copy selection from modal (opt-in)
try:
# Clear any prior selection first; this flow should define it explicitly when present
if "multi_copy" in sess:
del sess["multi_copy"]
if enable_multicopy and multi_choice_id and str(multi_choice_id).strip():
meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {})
printed_cap = meta.get("printed_cap")
cnt: int
if multi_count is None:
cnt = int(meta.get("default_count", 25))
else:
try:
cnt = int(multi_count)
except Exception:
cnt = int(meta.get("default_count", 25))
if isinstance(printed_cap, int) and printed_cap > 0:
cnt = max(1, min(printed_cap, cnt))
sess["multi_copy"] = {
"id": str(multi_choice_id),
"name": meta.get("name") or str(multi_choice_id),
"count": int(cnt),
"thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False,
}
else:
# Ensure disabled when not opted-in
if "multi_copy" in sess:
del sess["multi_copy"]
# Reset the applied marker so the run can account for the new selection
if "mc_applied_key" in sess:
del sess["mc_applied_key"]
except Exception:
pass
# Clear any old staged build context
for k in ["build_ctx", "locks", "replace_mode"]:
if k in sess:
@ -447,13 +552,12 @@ async def build_new_submit(
del sess[k]
except Exception:
pass
# Reset multi-copy suggestion debounce and selection for a fresh run
for k in ["mc_seen_keys", "multi_copy"]:
if k in sess:
try:
del sess[k]
except Exception:
pass
# Reset multi-copy suggestion debounce for a fresh run (keep selected choice)
if "mc_seen_keys" in sess:
try:
del sess["mc_seen_keys"]
except Exception:
pass
# Persist optional custom export base name
if isinstance(name, str) and name.strip():
sess["custom_export_base"] = name.strip()
@ -496,6 +600,13 @@ async def build_new_submit(
# Centralized staged context creation
sess["build_ctx"] = start_ctx_from_session(sess)
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try:
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception:
pass
status = "Build complete" if res.get("done") else "Stage complete"
sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False)

View file

@ -84,7 +84,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; }
.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.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; }
@ -107,6 +107,27 @@ body {
}
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
/* Collapsible sidebar behavior */
body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); }
body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; }
body.nav-collapsed .content{ grid-column: 2; }
body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; }
body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; }
/* Smooth hide/show on mobile while keeping fixed positioning */
.sidebar{ transition: transform .2s ease-out, visibility .2s linear; }
/* Mobile tweaks */
@media (max-width: 900px){
:root{ --sidebar-w: 240px; }
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem .5rem; }
.banner-status{ padding-left: .5rem; }
.layout{ grid-template-columns: 0 1fr; }
.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; }
}
.brand h1{ display:none; }
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
@ -128,6 +149,13 @@ body {
/* Left-rail variant puts the image first */
.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
/* Ensure left-rail variant also collapses to 1 column on small screens */
@media (max-width: 900px){
.two-col.two-col-left-rail{ grid-template-columns: 1fr; }
/* So the commander image doesn't dominate on mobile */
.two-col .card-preview{ max-width: 360px; margin: 0 auto; }
.two-col .card-preview img{ width: 100%; height: auto; }
}
.card-preview.card-sm{ max-width:200px; }
/* Buttons, inputs */
@ -184,6 +212,11 @@ small, .muted{ color: var(--muted); }
margin-top:.5rem;
justify-content: start; /* pack as many as possible per row */
}
@media (max-width: 420px){
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card-tile{ width: 100%; }
.card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; }
}
.card-tile{
width:170px;
position: relative;
@ -256,9 +289,14 @@ small, .muted{ color: var(--muted); }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks for small screens */
/* Build controls sticky box tweaks */
.build-controls { top: calc(var(--banner-offset, 48px) + 6px); }
@media (max-width: 720px){
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
:root { --banner-offset: 56px; }
.build-controls { position: sticky; border-radius: 8px; margin-left: 0; margin-right: 0; }
}
@media (min-width: 721px){
:root { --banner-offset: 48px; }
}
/* Progress bar */

View file

@ -30,7 +30,7 @@
}catch(_){ }
})();
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
@ -45,7 +45,12 @@
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
<header class="top-banner">
<div class="top-inner">
<h1>MTG Deckbuilder</h1>
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
☰ Menu
</button>
<h1 style="margin:0;">MTG Deckbuilder</h1>
</div>
<div style="display:flex; align-items:center; gap:.5rem">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
@ -70,7 +75,7 @@
</div>
</header>
<div class="layout">
<aside class="sidebar">
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
<div class="brand">
<div class="mana-dots" aria-hidden="true">
<span class="dot green"></span>
@ -117,9 +122,49 @@
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
footer.site-footer { flex-shrink: 0; }
/* Hide hover preview on narrow screens to avoid covering content */
@media (max-width: 900px){
.card-hover{ display: none !important; }
}
</style>
<script>
(function(){
// Sidebar toggle and persistence
try{
var BODY = document.body;
var SIDEBAR = document.getElementById('sidebar');
var TOGGLE = document.getElementById('nav-toggle');
var KEY = 'mtg:navCollapsed';
function apply(collapsed){
if (collapsed){
BODY.classList.add('nav-collapsed');
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'false');
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'true');
} else {
BODY.classList.remove('nav-collapsed');
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'true');
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'false');
}
}
// Initial state: respect saved pref, else collapse on small screens
var saved = localStorage.getItem(KEY);
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
apply(initialCollapsed);
if (TOGGLE){
TOGGLE.addEventListener('click', function(){
var isCollapsed = BODY.classList.contains('nav-collapsed');
apply(!isCollapsed);
try{ localStorage.setItem(KEY, (!isCollapsed) ? '1' : '0'); }catch(_){ }
});
}
// Keep ARIA in sync on resize for first-load default when no pref yet
window.addEventListener('resize', function(){
// Do not override if user has an explicit preference saved
if (localStorage.getItem(KEY) !== null) return;
apply((window.innerWidth || 0) < 900);
});
}catch(_){ }
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){

View file

@ -1 +1 @@
<div id="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
<div id="banner-status" class="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>

View file

@ -1,6 +1,6 @@
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
<div class="modal-backdrop" style="position:fixed; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
<div class="modal-header">
<h3 id="newDeckTitle">Build a New Deck</h3>
</div>
@ -10,7 +10,7 @@
<form hx-post="/build/new" hx-target="#wizard" hx-swap="innerHTML" hx-on="htmx:afterRequest: (function(evt){ try{ if(evt && evt.detail && evt.detail.elt === this){ var m=this.closest('.modal'); if(m){ m.remove(); } } }catch(_){} }).call(this, event)" autocomplete="off">
<fieldset>
<legend>Basics</legend>
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
<div>
<label style="display:block; margin-bottom:.5rem;">
<span class="muted">Optional name (used for file names)</span>
@ -39,6 +39,7 @@
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
<div style="margin-top:.5rem;">
<label>Bracket
<select name="bracket">
@ -54,6 +55,10 @@
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
</label>
<div style="margin-top:.35rem;"></div>
<label title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" /> Enable Multi-Copy package
</label>
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<label>
@ -95,14 +100,42 @@
<script>
(function(){
// Backdrop click to close
try{
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
var backdrop = modal ? modal.querySelector('.modal-backdrop') : null;
if (backdrop){ backdrop.addEventListener('click', function(){ try{ modal.remove(); }catch(_){} }); }
}catch(_){ }
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
// Prevent Enter in text inputs from submitting the form
try {
var form = modal ? modal.querySelector('form') : document.querySelector('.modal form');
if (form){
// Prevent Enter in name field from submitting
// Form-level Enter: if suggestions exist, pick the first by default
form.addEventListener('keydown', function(e){
try{
if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
var list = document.getElementById('newdeck-candidates');
var first = list && list.querySelector('button.candidate-btn');
if (first){
e.preventDefault(); e.stopPropagation(); first.click();
} else {
// No suggestions: allow normal form submit
}
}catch(_){ }
}, true);
// Name field: only block Enter if suggestions exist; otherwise allow submit
var nameEl = form.querySelector('input[name="name"]');
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
if (nameEl){
nameEl.addEventListener('keydown', function(e){
if (e.key !== 'Enter') return;
try{
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (hasBtns){ e.preventDefault(); e.stopPropagation(); }
}catch(_){ }
});
}
// In commander field, Enter picks the first candidate (if any) without closing the modal
var cmdEl = form.querySelector('input[name=\"commander\"]');
if (cmdEl){
@ -186,4 +219,62 @@
sync();
} catch(_){}
})();
// Integrated Multi-Copy: fetch suggestions once commander + tags are present
(function(){
function fetchMulti(){
try{
var slot = document.getElementById('newdeck-multicopy-slot');
var form = document.querySelector('.modal form');
if (!slot || !form) return;
var enable = form.querySelector('#pref-mc-chk');
if (!enable || !enable.checked){ slot.innerHTML = ''; return; }
var cmd = form.querySelector('input[name="commander"]').value.trim();
var p = form.querySelector('input[name="primary_tag"]').value.trim();
var s = form.querySelector('input[name="secondary_tag"]').value.trim();
var t = form.querySelector('input[name="tertiary_tag"]').value.trim();
var mode = form.querySelector('input[name="tag_mode"]').value.trim() || 'AND';
if (!cmd) { slot.innerHTML = ''; return; }
// Only fetch if at least one tag is present (to avoid noise)
if (!(p || s || t)) { slot.innerHTML = ''; return; }
var params = new URLSearchParams({ commander: cmd, tag_mode: mode });
if (p) params.append('primary_tag', p); if (s) params.append('secondary_tag', s); if (t) params.append('tertiary_tag', t);
fetch('/build/new/multicopy?' + params.toString(), { headers: { 'HX-Request': 'true' } })
.then(function(r){ return r.text(); })
.then(function(html){ slot.innerHTML = html; })
.catch(function(){ slot.innerHTML = ''; });
}catch(_){ }
}
// Listen for OOB updates to the tags slot to trigger fetch
document.body.addEventListener('htmx:afterSwap', function(ev){
try{
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
if (tgt && tgt.id === 'newdeck-tags-slot'){ fetchMulti(); }
}catch(_){ }
});
// Respond to explicit tag-change signal from the tags partial
try{ document.addEventListener('newdeck:tagsChanged', fetchMulti); }catch(_){ }
// Also debounce on commander input changes
try{
var cmdEl = document.querySelector('.modal form input[name="commander"]');
var timer;
if (cmdEl){ cmdEl.addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(fetchMulti, 300); }); }
}catch(_){ }
// React to preference toggle
try{
var mcChk = document.querySelector('.modal form #pref-mc-chk');
if (mcChk){ mcChk.addEventListener('change', fetchMulti); }
}catch(_){ }
})();
</script>
<style>
/* Modal responsive tweaks (scoped) */
@media (max-width: 720px){
.modal .basics-grid{ grid-template-columns: 1fr !important; }
#newdeck-commander-slot{ max-width: 100% !important; }
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
.modal .modal-content{ width: min(95vw, 560px) !important; }
}
</style>

View file

@ -0,0 +1,59 @@
{% if items and items|length %}
<fieldset id="mc-integrated" style="margin-top:.75rem;">
<legend>Optional: Multi-Copy package</legend>
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
<div style="display:grid; gap:.5rem;">
{% for it in items %}
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
<div>
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
{% if it.reasons %}
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
{% set first = items[0] %}
{% set cap = first.printed_cap %}
{% set rec = first.rec_window if first.rec_window else (20,30) %}
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;">
<label>Copies <input type="number" min="1" name="multi_count" value="{{ first.default_count or 25 }}" style="width:6rem; margin-left:.35rem;"></label>
{% if cap %}
<small class="muted">Max {{ cap }}</small>
{% else %}
<small class="muted">Suggested {{ rec[0] }}{{ rec[1] }}</small>
{% endif %}
</div>
<div id="mc-thrum-row" style="margin-top:.35rem;">
<label title="Adds 1 copy of Thrumming Stone if applicable.">
<input type="checkbox" name="multi_thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
</label>
</div>
<div class="muted" style="font-size:12px; margin-top:.35rem;">You can leave this unselected to skip multi-copy for this build.</div>
</fieldset>
<script>
(function(){
var root = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling : document;
var container = root.querySelector ? root : document;
var fieldset = container.querySelector('#mc-integrated');
if (!fieldset) return;
function updateForChoice(){
try{
var checked = fieldset.querySelector('input[name="multi_choice_id"]:checked');
var count = fieldset.querySelector('input[name="multi_count"]');
if (!checked || !count) return;
// Use label text to parse Cap when present
var label = checked.closest('label.mc-option');
var capEl = label && label.querySelector('.muted');
var m = capEl && capEl.textContent && capEl.textContent.match(/Cap:\s*(\d+)/);
if (m){ var cap = parseInt(m[1],10); count.max = String(cap); if (parseInt(count.value||'0',10) > cap) count.value = String(cap); }
else { count.removeAttribute('max'); }
}catch(_){}
}
fieldset.querySelectorAll('input[name="multi_choice_id"]').forEach(function(r){ r.addEventListener('change', updateForChoice); });
updateForChoice();
})();
</script>
{% endif %}

View file

@ -94,6 +94,8 @@
}catch(_){ }
function apply(container){ if(!container) return; var chips = container.querySelectorAll('button.chip'); chips.forEach(function(btn){ var tag=btn.dataset.tag||''; var active=getSel().indexOf(tag)>=0; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active?'true':'false'); }); }
apply(list); apply(reco);
// Notify parent modal so it can refresh multi-copy suggestions
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
}
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });

View file

@ -8,7 +8,6 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="commander" value="{{ commander.name }}" />

View file

@ -9,7 +9,6 @@
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>

View file

@ -8,7 +8,6 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
{% if locks_restored and locks_restored > 0 %}
<div class="muted" style="margin:.35rem 0;">
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>

View file

@ -26,7 +26,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -137,7 +137,7 @@
</div>
<!-- Sticky build controls on mobile -->
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<div class="build-controls" style="position:sticky; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "2.2.3"
version = "2.2.4"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}