diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d20e8..bf39a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 875ce75..3cfee00 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -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. ## What’s 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) \ No newline at end of file +- 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. \ No newline at end of file diff --git a/code/web/routes/build.py b/code/web/routes/build.py index d48eb9d..ff28969 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -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) diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 408233f..7e42ed5 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -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 */ diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 106e16d..eee8400 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -30,7 +30,7 @@ }catch(_){ } })(); - + @@ -45,7 +45,12 @@
-

MTG Deckbuilder

+
+ +

MTG Deckbuilder

+
@@ -70,7 +75,7 @@
-
-
{% if locks_restored and locks_restored > 0 %}
🔒 {{ locks_restored }} locks restored diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 273096f..136cce1 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -26,7 +26,7 @@
-
+

Commander: {{ commander }}

Tags: {{ tags|default([])|join(', ') }}

@@ -137,7 +137,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml index 72063f6..214a061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}