feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)

This commit is contained in:
matt 2025-08-28 14:57:22 -07:00
parent f8c6b5c07e
commit 721e1884af
41 changed files with 2960 additions and 143 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
*.txt
.mypy_cache/
.venv/
.pytest_cache/
test.py
!requirements.txt
__pycache__/

View file

@ -13,7 +13,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Added
- Web UI performance: optional virtualized grids/lists in Step 5 and Owned (enable with `WEB_VIRTUALIZE=1`).
- Virtualization diagnostics overlay (when `SHOW_DIAGNOSTICS=1`); press `v` to toggle pergrid overlays and a global summary bubble with visible range, totals, render time, and counters.
- Image polish: lazyloading with responsive `srcset/sizes` and LQIP blur/fadein for Step 5 and Owned thumbnails and the commander preview image.
- ShortTTL fragment caching for template partials (e.g., finished deck summaries and config run summaries) to reduce rerender cost.
- Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons
- New Deck modal consolidating steps 13 with optional Name for exports, Enter-to-select commander, and disabled browser autofill
- Locks, Replace flow, Compare builds, and shareable permalinks for finished decks
- Compare page: Copy summary action to copy diffs (Only in A/B and Changed counts) to clipboard
- Finished Decks multi-select → Compare with fallback to "Latest two"; options carry modified-time for ordering
- Permalinks include locks; global "Open Permalink…" entry exposed in header and Finished Decks
- Replace flow supports session-local Undo and lock-aware validation
- New Deck modal: inline summary of selected themes with order (1, 2, 3)
- Theme combine mode (AND/OR) with tooltips and selection-order display in the Web UI
- AND-mode creatures pre-pass: select "all selected themes" creatures first, then fill by weighted overlap; staged reasons show matched themes
- Scryfall attribution footer in the Web UI
@ -39,7 +50,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics
- Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists
Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outline.md`.
### Changed
- Accessibility: respect OS “reduced motion” by disabling blur/fade transitions and smooth scrolling.
- Static asset caching and compression tuned for the web service (cache headers + gzip) to improve load performance.
- Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected)
- Docker assets and docs updated:
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
@ -51,6 +66,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Builder Review (Step 4): "Use only owned cards" toggle moved here; Step 5 is status-only with "Edit in Review" for changes
- Minor UI/CSS polish and consolidation across builder/owned pages
- Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources
- New Deck modal submits directly to build, removing the intermediate review step
- Finished Decks banner and lists now prefer the custom Name provided in the modal
- Step 5 Replace toggle now includes a tooltip clarifying that reruns will replace picks in that stage when enabled
- Locks are enforced on rerun; the Locked section live-updates on unlock (row removal and chip refresh)
- Compare page shows ▲/▼ indicators on Changed counts and preserves the "Changed only" toggle across interactions
- Bracket selector shows numbered labels (e.g., "Bracket 3: Upgraded") and defaults to bracket 3 on new deck creation
- List view highlight polished to wrap only the card name (no overrun of the row)
- Total sources calculation updated to include 'C' properly
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
@ -66,7 +87,9 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export
- Fetch lands are no longer miscounted as mana sources
- Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler
- Windows PowerShell curl parsing issue documented with guidance in README
- Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints
- Banned list filtering applied consistently to all color/guild CSV generation paths with exact, case-insensitive matching on name/faceName (e.g., Hullbreacher, Dockside Extortionist, and Lutri are excluded)
---

View file

@ -34,6 +34,36 @@ Then open http://localhost:8080
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`.
Compare view offers a Copy summary button to copy a plain-text diff of two runs.
Web UI feature highlights:
- Locks: Click a card or the lock control in Step 5; locks persist across reruns.
- Replace: Enable Replace in Step 5, click a card to open Alternatives (filters include Owned-only), then choose a swap.
- Permalinks: Copy a permalink from Step 5 or a Finished deck; paste via “Open Permalink…” to restore.
- Compare: Use the Compare page from Finished Decks; quick actions include Latest two and Swap A/B.
Virtualized lists and lazy images (optin)
- Set `WEB_VIRTUALIZE=1` to enable virtualization in Step 5 grids/lists and the Owned library for smoother scrolling on large sets.
- Example (Compose):
```yaml
services:
web:
environment:
- WEB_VIRTUALIZE=1
```
- Example (Docker Hub):
```powershell
docker run --rm -p 8080:8080 `
-e WEB_VIRTUALIZE=1 `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
-v "${PWD}/owned_cards:/app/owned_cards" `
-v "${PWD}/config:/app/config" `
-e SHOW_DIAGNOSTICS=1 ` # optional: enables diagnostics tools and overlay
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
```
### Diagnostics and logs (optional)
Enable internal diagnostics and a read-only logs viewer with environment flags.
@ -44,6 +74,7 @@ Enable internal diagnostics and a read-only logs viewer with environment flags.
When enabled:
- `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail.
- `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`.
- Virtualization overlay: press `v` on pages with virtualized grids to toggle per-grid overlays and a global summary bubble.
Compose example (web service):
```yaml
@ -125,6 +156,8 @@ GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "upti
### Web UI tuning env vars
- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off)
- WEB_TAG_WORKERS=<N> (process count; set based on CPU/memory)
- WEB_VIRTUALIZE=1 (enable virtualization)
- SHOW_DIAGNOSTICS=1 (enables diagnostics pages and overlay hotkey `v`)
## Manual build/run
```powershell

View file

@ -48,3 +48,7 @@ WORKDIR /app/code
# Run the application
CMD ["python", "main.py"]
# Note: For the Web UI, start uvicorn in your orchestrator (compose/run) like:
# uvicorn code.web.app:app --host 0.0.0.0 --port 8080
# Phase 9: enable web list virtualization with env WEB_VIRTUALIZE=1

BIN
README.md

Binary file not shown.

View file

@ -8,6 +8,10 @@
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
- Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=<N>` (compose default: 4). Falls back to sequential if parallel init fails.
- Phase 8 UI upgrade: Unified “New Deck” modal (steps 13), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
- Compare page now includes a Copy summary button to quickly share diffs.
- New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking.
- Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled.
- Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources.
- Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback).
- Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts.
@ -16,6 +20,10 @@
- Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label.
- Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews.
- Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesnt shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability.
- Finished Decks: banner and lists prefer the runs custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display.
- Replace toggle includes a tooltip explaining that reruns will replace that stages picks when enabled.
- Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck.
- Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal.
### Diagnostics and error handling
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
@ -91,6 +99,7 @@ docker compose up --no-deps web
- Finished Decks page uses a dropdown theme filter with shareable state.
- Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity.
- Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths.
- Data integrity: per-color/guild CSVs now consistently respect the Commander banned list using exact, case-insensitive name/faceName matching.
### Tagging updates
- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.

View file

@ -46,6 +46,7 @@ Run the browser UI by mapping a port and starting uvicorn:
```powershell
docker run --rm `
-p 8080:8080 `
-e WEB_VIRTUALIZE=1 ` # optional virtualization
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `

View file

@ -343,6 +343,15 @@ class ReportingMixin:
return candidate
i += 1
if filename is None:
# Build a filename stem from either custom export base or commander/themes
try:
custom_base = getattr(self, 'custom_export_base', None)
except Exception:
custom_base = None
date_part = _dt.date.today().strftime('%Y%m%d')
if isinstance(custom_base, str) and custom_base.strip():
stem = f"{_slug(custom_base.strip())}_{date_part}"
else:
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
# Collect themes in order
@ -357,8 +366,8 @@ class ReportingMixin:
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
date_part = _dt.date.today().strftime('%Y%m%d')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
filename = f"{stem}.csv"
fname = _unique_path(os.path.join(directory, filename))
full_df = getattr(self, '_full_cards_df', None)
@ -534,6 +543,15 @@ class ReportingMixin:
return candidate
i += 1
if filename is None:
# Prefer custom export base if provided; else fall back to commander/themes
try:
custom_base = getattr(self, 'custom_export_base', None)
except Exception:
custom_base = None
date_part = _dt.date.today().strftime('%Y%m%d')
if isinstance(custom_base, str) and custom_base.strip():
stem = f"{_slug(custom_base.strip())}_{date_part}"
else:
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
themes: List[str] = []
@ -547,8 +565,8 @@ class ReportingMixin:
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
date_part = _dt.date.today().strftime('%Y%m%d')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
filename = f"{stem}.txt"
if not filename.lower().endswith('.txt'):
filename = filename + '.txt'
path = _unique_path(os.path.join(directory, filename))
@ -643,6 +661,15 @@ class ReportingMixin:
i += 1
if filename is None:
# Prefer a custom export base when present; else commander/themes
try:
custom_base = getattr(self, 'custom_export_base', None)
except Exception:
custom_base = None
date_part = _dt.date.today().strftime('%Y%m%d')
if isinstance(custom_base, str) and custom_base.strip():
stem = f"{_slug(custom_base.strip())}_{date_part}"
else:
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
themes: List[str] = []
@ -656,8 +683,8 @@ class ReportingMixin:
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
date_part = _dt.date.today().strftime('%Y%m%d')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
filename = f"{stem}.json"
path = _unique_path(os.path.join(directory, filename))

View file

@ -208,8 +208,8 @@ def regenerate_csv_by_color(color: str) -> None:
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
logger.info(f'Regenerating {color} cards CSV')
# Use shared utilities to base-filter once then slice color
base_df = filter_dataframe(df, [])
# Use shared utilities to base-filter once then slice color, honoring bans
base_df = filter_dataframe(df, BANNED_CARDS)
base_df[base_df['colorIdentity'] == color_abv].to_csv(
f'{CSV_DIRECTORY}/{color}_cards.csv', index=False
)

View file

@ -36,7 +36,8 @@ from .setup_constants import (
COLUMN_ORDER,
TAGGED_COLUMN_ORDER,
SETUP_COLORS,
COLOR_ABRV
COLOR_ABRV,
BANNED_CARDS,
)
from exceptions import (
MTGJSONDownloadError,
@ -138,7 +139,8 @@ def save_color_filtered_csvs(df: pd.DataFrame, out_dir: Union[str, Path]) -> Non
# Base-filter once for efficiency, then per-color filter without redoing base filters
try:
base_df = filter_dataframe(df, [])
# Apply full standard filtering including banned list once, then slice per color
base_df = filter_dataframe(df, BANNED_CARDS)
except Exception as e:
# Wrap any unexpected issues as DataFrameProcessingError
raise DataFrameProcessingError(
@ -207,10 +209,16 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
logger.debug('Removed illegal sets')
# Remove banned cards
for card in banned_cards:
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
logger.debug('Removed banned cards')
# Remove banned cards (exact, case-insensitive match on name or faceName)
if banned_cards:
banned_set = {b.casefold() for b in banned_cards}
name_lc = filtered_df['name'].astype(str).str.casefold()
face_lc = filtered_df['faceName'].astype(str).str.casefold()
mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set))
before = len(filtered_df)
filtered_df = filtered_df[mask]
after = len(filtered_df)
logger.debug(f'Removed banned cards: {before - after} filtered out')
# Remove special card types
for card_type in CARD_TYPES_TO_EXCLUDE:
@ -268,7 +276,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
# Apply base filtering
with tqdm(total=1, desc='Applying base filtering') as pbar:
filtered_df = filter_dataframe(df, [])
filtered_df = filter_dataframe(df, BANNED_CARDS)
pbar.update(1)
# Filter by color identity

View file

@ -0,0 +1,68 @@
import importlib
from starlette.testclient import TestClient
class FakeBuilder:
def __init__(self):
# Minimal attributes accessed by /build/alternatives
self._card_name_tags_index = {
'target card': ['ramp', 'mana'],
'alt good': ['ramp', 'mana'],
'alt owned': ['ramp'],
'alt commander': ['ramp'],
'alt in deck': ['ramp'],
'alt locked': ['ramp'],
'unrelated': ['draw'],
}
# Simulate pandas DataFrame mapping to preserve display casing
# Represented as a simple mock object with .empty and .iterrows() for keys above
class DF:
empty = False
def __init__(self, names):
self._names = names
def __getattr__(self, name):
if name == 'empty':
return False
raise AttributeError
def __iter__(self):
return iter(self._names)
# We'll emulate minimal API used: df[ df["name"].astype(str).str.lower().isin(pool) ]
# To keep it simple, we won't rely on DF in this test; display falls back to lower-case names.
self._combined_cards_df = None
self.card_library = {}
# Simulate deck names containing 'alt in deck'
self.current_names = ['alt in deck']
def _inject_fake_ctx(client: TestClient, commander: str, locks: list[str]):
# Touch session to get sid cookie
r = client.get('/build')
assert r.status_code == 200
sid = r.cookies.get('sid')
assert sid
# Import session service and mutate directly
tasks = importlib.import_module('code.web.services.tasks')
sess = tasks.get_session(sid)
sess['commander'] = commander
sess['locks'] = locks
sess['build_ctx'] = {
'builder': FakeBuilder(),
'locks': {s.lower() for s in locks},
}
return sid
def test_alternatives_filters_out_commander_in_deck_and_locked():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
_inject_fake_ctx(client, commander='Alt Commander', locks=['alt locked'])
# owned_only off
r = client.get('/build/alternatives?name=Target%20Card&owned_only=0')
assert r.status_code == 200
body = r.text.lower()
# Should include alt good and alt owned, but not commander, in deck, or locked
assert 'alt good' in body or 'alt%20good' in body
assert 'alt owned' in body or 'alt%20owned' in body
assert 'alt commander' not in body
assert 'alt in deck' not in body
assert 'alt locked' not in body

View file

@ -0,0 +1,52 @@
import os
import tempfile
from pathlib import Path
import importlib
from starlette.testclient import TestClient
def _write_csv(p: Path, rows):
p.write_text('\n'.join(rows), encoding='utf-8')
def test_compare_diffs_with_temp_exports(monkeypatch):
with tempfile.TemporaryDirectory() as tmpd:
tmp = Path(tmpd)
# Create two CSV exports with small differences
a = tmp / 'A.csv'
b = tmp / 'B.csv'
header = 'Name,Count,Type,ManaValue\n'
_write_csv(a, [
header.rstrip('\n'),
'Card One,1,Creature,2',
'Card Two,2,Instant,1',
'Card Three,1,Sorcery,3',
])
_write_csv(b, [
header.rstrip('\n'),
'Card Two,1,Instant,1', # decreased in B
'Card Four,1,Creature,2', # only in B
'Card Three,1,Sorcery,3',
])
# Touch mtime so B is newer
os.utime(a, None)
os.utime(b, None)
# Point DECK_EXPORTS at this temp dir
monkeypatch.setenv('DECK_EXPORTS', str(tmp))
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Compare A vs B
r = client.get(f'/decks/compare?A={a.name}&B={b.name}')
assert r.status_code == 200
body = r.text
# Only in A: Card One
assert 'Only in A' in body
assert 'Card One' in body
# Only in B: Card Four
assert 'Only in B' in body
assert 'Card Four' in body
# Changed list includes Card Two with delta -1
assert 'Card Two' in body
assert 'Decreased' in body or '( -1' in body or '(-1)' in body

View file

@ -0,0 +1,12 @@
import importlib
from starlette.testclient import TestClient
def test_compare_options_include_mtime_attribute():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
r = client.get('/decks/compare')
assert r.status_code == 200
body = r.text
# Ensure at least one option contains data-mtime attribute (present even with empty list structure)
assert 'data-mtime' in body

View file

@ -0,0 +1,44 @@
import base64
import json
from starlette.testclient import TestClient
def test_permalink_includes_locks_and_restores_notice(monkeypatch):
# Lazy import to ensure fresh app state
import importlib
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Seed a session with a commander and locks by calling /build and directly touching session via cookie path
# Start a session
r = client.get('/build')
assert r.status_code == 200
# Now set some session state by invoking endpoints that mutate session
# Simulate selecting commander and a lock
# Use /build/from to load a permalink-like payload directly
payload = {
"commander": "Atraxa, Praetors' Voice",
"tags": ["proliferate"],
"bracket": 3,
"ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4},
"tag_mode": "AND",
"flags": {"owned_only": False, "prefer_owned": False},
"locks": ["Swords to Plowshares", "Sol Ring"],
}
raw = json.dumps(payload, separators=(",", ":")).encode('utf-8')
token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
r2 = client.get(f'/build/from?state={token}')
assert r2.status_code == 200
# Step 4 should contain the locks restored chip
body = r2.text
assert 'locks restored' in body.lower()
# Ask the server for a permalink now and ensure locks are present
r3 = client.get('/build/permalink')
assert r3.status_code == 200
data = r3.json()
# Prefer decoded state when token not provided
state = data.get('state') or {}
assert 'locks' in state
assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"}

View file

@ -0,0 +1,68 @@
import base64
import json
import importlib
from starlette.testclient import TestClient
def _decode_permalink_state(client: TestClient) -> dict:
r = client.get('/build/permalink')
assert r.status_code == 200
data = r.json()
if data.get('state'):
return data['state']
# If only permalink token provided, decode it for inspection
url = data.get('permalink') or ''
assert '/build/from?state=' in url
token = url.split('state=', 1)[1]
pad = '=' * (-len(token) % 4)
raw = base64.urlsafe_b64decode((token + pad).encode('ascii')).decode('utf-8')
return json.loads(raw)
def test_replace_updates_locks_and_undo_restores(monkeypatch):
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Start session
r = client.get('/build')
assert r.status_code == 200
# Replace Old -> New (locks: add new, remove old)
r2 = client.post('/build/replace', data={'old': 'Old Card', 'new': 'New Card'})
assert r2.status_code == 200
body = r2.text
assert 'Locked <strong>New Card</strong> and unlocked <strong>Old Card</strong>' in body
state = _decode_permalink_state(client)
locks = {s.lower() for s in state.get('locks', [])}
assert 'new card' in locks
assert 'old card' not in locks
# Undo should remove new and re-add old
r3 = client.post('/build/replace/undo', data={'old': 'Old Card', 'new': 'New Card'})
assert r3.status_code == 200
state2 = _decode_permalink_state(client)
locks2 = {s.lower() for s in state2.get('locks', [])}
assert 'old card' in locks2
assert 'new card' not in locks2
def test_lock_from_list_unlock_emits_oob_updates():
app_module = importlib.import_module('code.web.app')
client = TestClient(app_module.app)
# Initialize session
r = client.get('/build')
assert r.status_code == 200
# Lock a name
r1 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '1'})
assert r1.status_code == 200
# Now unlock from the locked list path (from_list=1)
r2 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '0', 'from_list': '1'})
assert r2.status_code == 200
body = r2.text
# Should include out-of-band updates so UI can refresh the locks chip/section
assert 'hx-swap-oob' in body
assert 'id="locks-chip"' in body or "id='locks-chip'" in body

View file

@ -11,6 +11,8 @@ import time
import uuid
import logging
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware
from typing import Any, Tuple
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -18,10 +20,20 @@ _TEMPLATES_DIR = _THIS_DIR / "templates"
_STATIC_DIR = _THIS_DIR / "static"
app = FastAPI(title="MTG Deckbuilder Web UI")
app.add_middleware(GZipMiddleware, minimum_size=500)
# Mount static if present
if _STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
class CacheStatic(StaticFiles):
async def get_response(self, path, scope): # type: ignore[override]
resp = await super().get_response(path, scope)
try:
# Add basic cache headers for static assets
resp.headers.setdefault("Cache-Control", "public, max-age=604800, immutable")
except Exception:
pass
return resp
app.mount("/static", CacheStatic(directory=str(_STATIC_DIR)), name="static")
# Jinja templates
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
@ -35,14 +47,42 @@ def _as_bool(val: str | None, default: bool = False) -> bool:
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
# Expose as Jinja globals so all templates can reference without passing per-view
templates.env.globals.update({
"show_logs": SHOW_LOGS,
"show_setup": SHOW_SETUP,
"show_diagnostics": SHOW_DIAGNOSTICS,
"virtualize": SHOW_VIRTUALIZE,
})
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {}
_FRAGMENT_TTL_SECONDS = 60.0
def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str:
"""Render a template fragment with an optional cache key and short TTL.
Intended for finished/immutable views (e.g., saved deck summaries). On error,
falls back to direct rendering without cache interaction.
"""
try:
if cache_key:
now = time.time()
k = (template_name, str(cache_key))
hit = _FRAGMENT_CACHE.get(k)
if hit and (now - hit[0]) < _FRAGMENT_TTL_SECONDS:
return hit[1]
html = templates.get_template(template_name).render(**ctx)
_FRAGMENT_CACHE[k] = (now, html)
return html
return templates.get_template(template_name).render(**ctx)
except Exception:
return templates.get_template(template_name).render(**ctx)
templates.env.globals["render_cached"] = render_cached
# --- Diagnostics: request-id and uptime ---
_APP_START_TIME = time.time()
@ -331,3 +371,11 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
@app.get("/diagnostics/perf", response_class=HTMLResponse)
async def diagnostics_perf(request: Request) -> HTMLResponse:
"""Synthetic scroll performance page (diagnostics only)."""
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/perf.html", {"request": request})

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from pathlib import Path
import csv
import os
from typing import Dict, List, Tuple
from typing import Dict, List, Tuple, Optional
from ..app import templates
from ..services import owned_store
@ -47,6 +47,8 @@ def _list_decks() -> list[dict]:
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
meta["commander"] = _m.get('commander') or meta.get("commander")
meta["tags"] = _m.get('tags') or meta.get("tags") or []
if _m.get('name'):
meta["display"] = _m.get('name')
except Exception:
pass
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
@ -213,6 +215,38 @@ def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, i
return summary, type_counts, curve_counts, type_cards
def _read_deck_counts(csv_path: Path) -> Dict[str, int]:
"""Read a CSV deck export and return a mapping of card name -> total count.
Falls back to zero on parse issues; ignores header case and missing columns.
"""
counts: Dict[str, int] = {}
try:
with csv_path.open('r', encoding='utf-8') as f:
reader = csv.reader(f)
headers = next(reader, [])
name_idx = headers.index('Name') if 'Name' in headers else 0
count_idx = headers.index('Count') if 'Count' in headers else 1
for row in reader:
if not row:
continue
try:
name = row[name_idx]
except Exception:
continue
try:
cnt = int(float(row[count_idx])) if row[count_idx] else 1
except Exception:
cnt = 1
name = str(name).strip()
if not name:
continue
counts[name] = counts.get(name, 0) + cnt
except Exception:
pass
return counts
@router.get("/", response_class=HTMLResponse)
async def decks_index(request: Request) -> HTMLResponse:
items = _list_decks()
@ -243,11 +277,14 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
_tags = meta.get('tags') or []
if isinstance(_tags, list):
tags = [str(t) for t in _tags]
display_name = meta.get('name') or ''
except Exception:
summary = None
display_name = ''
if not summary:
# Reconstruct minimal summary from CSV
summary, _tc, _cc, _tcs = _read_csv_summary(p)
display_name = ''
stem = p.stem
txt_path = p.with_suffix('.txt')
# If missing still, infer from filename stem
@ -263,7 +300,91 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"summary": summary,
"commander": commander_name,
"tags": tags,
"display_name": display_name,
"game_changers": bc.GAME_CHANGERS,
"owned_set": {n.lower() for n in owned_store.get_names()},
}
return templates.TemplateResponse("decks/view.html", ctx)
@router.get("/compare", response_class=HTMLResponse)
async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[str] = None) -> HTMLResponse:
"""Compare two finished deck CSVs and show diffs.
Query params:
- A: filename of first deck (e.g., Alena_..._20250827.csv)
- B: filename of second deck
"""
base = _deck_dir()
items = _list_decks()
# Build select options with friendly display labels
options: List[Dict[str, str]] = []
for it in items:
label = it.get("display") or it.get("commander") or it.get("name")
# Include mtime for "Latest two" selection refinement
mt = it.get("mtime", 0)
try:
mt_val = str(int(mt))
except Exception:
mt_val = "0"
options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type]
diffs = None
metaA: Dict[str, str] = {}
metaB: Dict[str, str] = {}
if A and B:
pA = (base / A)
pB = (base / B)
if _safe_within(base, pA) and _safe_within(base, pB) and pA.exists() and pB.exists():
ca = _read_deck_counts(pA)
cb = _read_deck_counts(pB)
setA = set(ca.keys())
setB = set(cb.keys())
onlyA = sorted(list(setA - setB))
onlyB = sorted(list(setB - setA))
changed: List[Tuple[str, int, int]] = []
for n in sorted(setA & setB):
if ca.get(n, 0) != cb.get(n, 0):
changed.append((n, ca.get(n, 0), cb.get(n, 0)))
# Side meta (commander/name/tags) if available
def _meta_for(path: Path) -> Dict[str, str]:
out: Dict[str, str] = {"filename": path.name}
sc = path.with_suffix('.summary.json')
try:
if sc.exists():
import json as _json
payload = _json.loads(sc.read_text(encoding='utf-8'))
if isinstance(payload, dict):
m = payload.get('meta', {}) or {}
out["display"] = (m.get('name') or '')
out["commander"] = (m.get('commander') or '')
out["tags"] = ', '.join(m.get('tags') or [])
except Exception:
pass
if not out.get("commander"):
parts = path.stem.split('_')
if parts:
out["commander"] = parts[0]
return out
metaA = _meta_for(pA)
metaB = _meta_for(pB)
diffs = {
"onlyA": onlyA,
"onlyB": onlyB,
"changed": changed,
"A": A,
"B": B,
}
return templates.TemplateResponse(
"decks/compare.html",
{
"request": request,
"options": options,
"A": A or "",
"B": B or "",
"diffs": diffs,
"metaA": metaA,
"metaB": metaB,
},
)

View file

@ -781,6 +781,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception as e:
out(f"Reporting phase failed: {e}")
try:
# If a custom export base is threaded via environment/session in web, we can respect env var
try:
custom_base = os.getenv('WEB_CUSTOM_EXPORT_BASE')
if custom_base:
setattr(b, 'custom_export_base', custom_base)
except Exception:
pass
if hasattr(b, 'export_decklist_csv'):
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
except Exception as e:
@ -819,6 +826,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
"csv": csv_path,
"txt": txt_path,
}
# Attach custom deck name if provided
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -898,6 +912,8 @@ def start_build_ctx(
use_owned_only: bool | None = None,
prefer_owned: bool | None = None,
owned_names: List[str] | None = None,
locks: List[str] | None = None,
custom_export_base: str | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
@ -974,6 +990,9 @@ def start_build_ctx(
"csv_path": None,
"txt_path": None,
"snapshot": None,
"history": [], # list of {i, key, label, snapshot}
"locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()},
"custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None,
}
return ctx
@ -1021,13 +1040,21 @@ def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False) -> Dict[str, Any]:
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False, *, replace: bool = False) -> Dict[str, Any]:
b: DeckBuilder = ctx["builder"]
stages: List[Dict[str, Any]] = ctx["stages"]
logs: List[str] = ctx["logs"]
locks_set: set[str] = set(ctx.get("locks") or [])
# If all stages done, finalize exports (interactive/manual build)
if ctx["idx"] >= len(stages):
# Apply custom export base if present in context
try:
custom_base = ctx.get("custom_export_base")
if custom_base:
setattr(b, 'custom_export_base', str(custom_base))
except Exception:
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
@ -1045,6 +1072,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
pass
except Exception as e:
logs.append(f"Text export failed: {e}")
# Final lock enforcement before finishing
try:
for lname in locks_set:
try:
# If locked card missing, attempt to add a placeholder entry
if lname not in {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}:
# Try to find exact name in dataframes
target_name = None
try:
df = getattr(b, '_combined_cards_df', None)
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
except Exception:
target_name = None
if target_name is None:
# As fallback, use the locked name as-is (display only)
target_name = lname
b.card_library[target_name] = {
'Count': 1,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
continue
except Exception:
pass
# Build structured summary for UI
summary = None
try:
@ -1067,6 +1124,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -1095,8 +1158,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
label = stage["label"]
runner_name = stage["runner_name"]
# Take snapshot before executing; for rerun, restore first if we have one
if rerun and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
# Take snapshot before executing; for rerun with replace, restore first if we have one
if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
_restore_builder(b, ctx["snapshot"]) # restore to pre-stage state
snap_before = _snapshot_builder(b)
@ -1112,6 +1175,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
logs.append(f"Runner not available: {runner_name}")
delta_log = "\n".join(logs[start_log:])
# Enforce locks immediately after the stage runs so they appear in added list
try:
for lname in locks_set:
try:
lib_keys_lower = {str(n).strip().lower(): str(n) for n in getattr(b, 'card_library', {}).keys()}
if lname not in lib_keys_lower:
# Try to resolve canonical name from DF
target_name = None
try:
df = getattr(b, '_combined_cards_df', None)
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
except Exception:
target_name = None
if target_name is None:
target_name = lname
b.card_library[target_name] = {
'Count': 1,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
continue
except Exception:
pass
# Compute added cards based on snapshot
try:
prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {}
@ -1170,6 +1263,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
added_total = 0
ctx["snapshot"] = snap_before # snapshot for rerun
try:
(ctx.setdefault("history", [])).append({
"i": i + 1,
"key": stage.get("key"),
"label": label,
"snapshot": snap_before,
})
except Exception:
pass
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
return {
@ -1196,6 +1298,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
total_cards = None
ctx["snapshot"] = snap_before
try:
(ctx.setdefault("history", [])).append({
"i": i + 1,
"key": stage.get("key"),
"label": label,
"snapshot": snap_before,
})
except Exception:
pass
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
return {
@ -1210,12 +1321,19 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"added_total": 0,
}
# No cards added and not showing skipped: advance to next
# No cards added and not showing skipped: advance to next stage and continue loop
i += 1
# Continue loop to auto-advance
# If we reached here, all remaining stages were no-ops; finalize exports
ctx["idx"] = len(stages)
# Apply custom export base if present
try:
custom_base = ctx.get("custom_export_base")
if custom_base:
setattr(b, 'custom_export_base', str(custom_base))
except Exception:
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
@ -1255,6 +1373,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv": ctx.get("csv_path"),
"txt": ctx.get("txt_path"),
}
try:
custom_base = getattr(b, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)

View file

@ -110,6 +110,13 @@
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
var k = e.key.toLowerCase();
// If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
try {
var active = document.activeElement;
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
return;
}
} catch(_) { /* noop */ }
if (keymap[k]){ e.preventDefault(); keymap[k](); }
});
@ -165,6 +172,7 @@
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
initVirtualization(document);
});
// Hydrate progress bars with width based on data-pct
@ -192,8 +200,31 @@
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
initVirtualization(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
function scrollCardIntoView(name){
if (!name) return;
try{
var section = document.querySelector('section');
var grid = section && section.querySelector('.card-grid');
if (!grid) return;
// If virtualized, force a render around the approximate match by searching stored children
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
if (!target) {
// Trigger a render update and try again
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
}
if (target) {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.focus && target.focus();
}
}catch(_){}
}
window.scrollCardIntoView = scrollCardIntoView;
// --- Card grid filters, reasons, and collapsible groups ---
function initCardFilters(root){
var section = (root || document).querySelector('section');
@ -368,4 +399,268 @@
}
document.addEventListener('keydown', onKey);
}
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
function initVirtualization(root){
try{
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
// Global diagnostics aggregator
var GLOBAL = (function(){
if (!DIAG) return null;
if (window.__virtGlobal) return window.__virtGlobal;
var store = { grids: [], summaryEl: null };
function ensure(){
if (!store.summaryEl){
var el = document.createElement('div');
el.id = 'virt-global-diag';
el.style.position = 'fixed';
el.style.right = '8px';
el.style.bottom = '8px';
el.style.background = 'rgba(17,24,39,.85)';
el.style.border = '1px solid var(--border)';
el.style.padding = '.25rem .5rem';
el.style.borderRadius = '6px';
el.style.fontSize = '12px';
el.style.color = '#cbd5e1';
el.style.zIndex = '50';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
el.style.cursor = 'default';
// Hidden by default; toggle with 'v'
el.style.display = 'none';
document.body.appendChild(el);
store.summaryEl = el;
}
return store.summaryEl;
}
function update(){
var el = ensure(); if (!el) return;
var g = store.grids;
var total = 0, visible = 0, lastMs = 0;
for (var i=0;i<g.length;i++){
total += g[i].total||0;
visible += (g[i].end||0) - (g[i].start||0);
lastMs = Math.max(lastMs, g[i].lastMs||0);
}
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
}
function register(gridId, ref){
store.grids.push({ id: gridId, ref: ref });
update();
return {
set: function(stats){
for (var i=0;i<store.grids.length;i++){
if (store.grids[i].id === gridId){
store.grids[i] = Object.assign({ id: gridId, ref: ref }, stats);
break;
}
}
update();
},
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
};
}
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
return window.__virtGlobal;
})();
// Support card grids and other scroll containers (e.g., #owned-box)
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
if (!grids.length) return;
grids.forEach(function(grid){
if (grid.__virtBound) return;
grid.__virtBound = true;
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
var container = grid;
container.style.position = container.style.position || 'relative';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
// Ensure wrapper itself is a grid to preserve multi-column layout inside
// when the container (e.g., .card-grid) is virtualized.
wrapper.style.display = 'grid';
// Move children into a fragment store (for owned, children live under UL)
var source = container;
// If this is the owned box, use the UL inside as the source list
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
var all = Array.prototype.slice.call(source.children);
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });
var padTop = document.createElement('div');
var padBottom = document.createElement('div');
padTop.style.height = '0px'; padBottom.style.height = '0px';
// For owned, keep the UL but render into it; otherwise append wrapper to container
if (ownedGrid){
ownedGrid.innerHTML = '';
ownedGrid.appendChild(padTop);
ownedGrid.appendChild(wrapper);
ownedGrid.appendChild(padBottom);
ownedGrid.appendChild(store);
} else {
container.appendChild(wrapper);
container.appendChild(padBottom);
container.appendChild(store);
}
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
var perRow = 1;
// Optional diagnostics overlay
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
var renderCount = 0; var measureCount = 0; var swapCount = 0;
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
function ensureDiag(){
if (!DIAG) return null;
if (diagBox) return diagBox;
diagBox = document.createElement('div');
diagBox.className = 'virt-diag';
diagBox.style.position = 'sticky';
diagBox.style.top = '0';
diagBox.style.zIndex = '5';
diagBox.style.background = 'rgba(17,24,39,.85)';
diagBox.style.border = '1px solid var(--border)';
diagBox.style.padding = '.25rem .5rem';
diagBox.style.borderRadius = '6px';
diagBox.style.fontSize = '12px';
diagBox.style.margin = '0 0 .35rem 0';
diagBox.style.color = '#cbd5e1';
diagBox.style.display = 'none'; // hidden until toggled
// Controls
var controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '.35rem';
controls.style.alignItems = 'center';
controls.style.marginBottom = '.25rem';
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
btnCopy.addEventListener('click', function(){ try{ var payload = {
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
});
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
diagBox.appendChild(controls);
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
host.insertBefore(diagBox, host.firstChild);
return diagBox;
}
function measure(){
try {
measureCount++;
// create a temp tile to measure if none
var probe = store.firstElementChild || all[0];
if (probe){
var fake = probe.cloneNode(true);
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
(ownedGrid || container).appendChild(fake);
var rect = fake.getBoundingClientRect();
rowH = Math.max(120, Math.ceil(rect.height) + 16);
(ownedGrid || container).removeChild(fake);
}
// Estimate perRow via computed styles of grid
var style = window.getComputedStyle(ownedGrid || container);
var cols = style.getPropertyValue('grid-template-columns');
// Mirror grid settings onto the wrapper so its children still flow in columns
try {
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
if (gap && gap.trim()) wrapper.style.gap = gap;
// Inherit justify/align if present
var ji = style.getPropertyValue('justify-items');
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
var ai = style.getPropertyValue('align-items');
if (ai && ai.trim()) wrapper.style.alignItems = ai;
} catch(_) {}
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
} catch(_){}
}
measure();
var total = all.length;
var start = 0, end = 0;
function render(){
var t0 = DIAG ? performance.now() : 0;
var scroller = container;
var vh = scroller.clientHeight || window.innerHeight;
var scrollTop = scroller.scrollTop;
// If container isnt scrollable, use window scroll offset
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
var newStart = rowStart * perRow;
var newEnd = Math.min(total, rowEnd * perRow);
if (newStart === start && newEnd === end) return; // no change
start = newStart; end = newEnd;
// Padding
var beforeRows = Math.floor(start / perRow);
var afterRows = Math.ceil((total - end) / perRow);
padTop.style.height = (beforeRows * rowH) + 'px';
padBottom.style.height = (afterRows * rowH) + 'px';
// Render visible children
wrapper.innerHTML = '';
for (var i = start; i < end; i++) {
var node = all[i];
if (node) wrapper.appendChild(node);
}
if (DIAG){
var box = ensureDiag();
if (box){
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
var textEl = box.querySelector('.virt-diag-text');
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
textEl.textContent = msg;
// Health hint
var bad = (dt > 33) || (vis > 300);
var warn = (!bad) && ((dt > 16) || (vis > 200));
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
}
}
}
function onScroll(){ render(); }
function onResize(){ measure(); render(); }
container.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// Initial size; ensure container is scrollable for our logic
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
container.style.overflow = container.style.overflow || 'auto';
render();
// Re-render after filters resort or HTMX swaps
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
// Keyboard toggle for overlays: 'v'
if (DIAG && !window.__virtHotkeyBound){
window.__virtHotkeyBound = true;
document.addEventListener('keydown', function(e){
try{
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key && e.key.toLowerCase() === 'v'){
e.preventDefault();
// Toggle all virt-diag boxes and the global summary
var shown = null;
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
}
}catch(_){ }
});
}
});
}catch(_){ }
}
// LQIP blur/fade-in for thumbnails marked with data-lqip
document.addEventListener('DOMContentLoaded', function(){
try{
document.querySelectorAll('img[data-lqip]')
.forEach(function(img){
img.classList.add('lqip');
img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
});
}catch(_){ }
});
})();

View file

@ -130,6 +130,11 @@ small, .muted{ color: var(--muted); }
text-align:center;
}
.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
.card-tile.locked{
/* Subtle yellow/goldish-white accent for locked cards */
border-color: #f5e6a8; /* soft parchment gold */
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
}
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
@ -175,6 +180,9 @@ small, .muted{ color: var(--muted); }
.game-changer { color: var(--green-main); }
.stack-card.game-changer { outline: 2px solid var(--green-main); }
/* Image button inside card tiles */
.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
/* Stage Navigator */
.stage-nav { margin:.5rem 0 1rem; }
.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
@ -221,3 +229,19 @@ small, .muted{ color: var(--muted); }
/* Inline error banner */
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
.inline-error-banner .muted{ color:#fda4af; }
/* Alternatives panel */
.alts ul{ list-style:none; padding:0; margin:0; }
.alts li{ display:flex; align-items:center; gap:.4rem; }
/* LQIP blur/fade-in for thumbnails */
img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
img.lqip.loaded { filter: blur(0); opacity: 1; }
/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
}
/* Virtualization wrapper should mirror grid to keep multi-column flow */
.virt-wrapper { display: grid; }

View file

@ -6,18 +6,23 @@
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
</head>
<body>
<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">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
<button type="button" class="btn" style="margin-left:.5rem;" title="Open a saved permalink"
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
</div>
</div>
</header>
@ -261,7 +266,7 @@
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script src="/static/app.js?v=20250826-2"></script>
<script src="/static/app.js?v=20250826-4"></script>
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){

View file

@ -1 +1 @@
<div id="banner-status" hx-swap-oob="true">{% if step %}<span class="muted">{{ step }}{% if i is not none and n is not none %} ({{ i }}/{{ n }}){% endif %}</span>: {% endif %}{% if commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} - {{ tags|join(', ') }}{% endif %}</div>
<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>

View file

@ -0,0 +1,18 @@
{% if candidates and candidates|length %}
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
{% for name, score, colors in candidates %}
<li>
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
hx-get="/build/new/inspect?name={{ name|urlencode }}"
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
{{ name }}
</button>
</li>
{% endfor %}
</ul>
{% else %}
{% if query %}
<div class="muted">No matches for “{{ query }}”.</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,149 @@
<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-header">
<h3 id="newDeckTitle">Build a New Deck</h3>
</div>
{% if error %}
<div class="error" role="alert" style="margin:.35rem 0 .5rem 0;">{{ error }}</div>
{% endif %}
<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>
<label style="display:block; margin-bottom:.5rem;">
<span class="muted">Optional name (used for file names)</span>
<input type="text" name="name" placeholder="e.g., Inti Discard Tempo" autocomplete="off" autocapitalize="off" spellcheck="false" />
</label>
<label style="display:block; margin-bottom:.5rem;">
<span>Commander</span>
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
hx-get="/build/new/candidates" hx-trigger="input changed delay:150ms" hx-target="#newdeck-candidates" hx-sync="this:replace" />
</label>
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>
</div>
<div id="newdeck-commander-slot" class="muted" style="max-width:230px;">
<em style="font-size:12px;">Pick a commander to preview here.</em>
</div>
</div>
</fieldset>
<fieldset>
<legend>Themes</legend>
<div id="newdeck-tags-slot" class="muted">
<em>Select a commander to see theme recommendations and choices.</em>
<input type="hidden" name="primary_tag" />
<input type="hidden" name="secondary_tag" />
<input type="hidden" name="tertiary_tag" />
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div style="margin-top:.5rem;">
<label>Bracket
<select name="bracket">
{% for b in brackets %}
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
{% endfor %}
</select>
</label>
</div>
</fieldset>
<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:flex-end; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<button type="submit" class="btn-continue">Create</button>
</div>
</form>
</div>
</div>
<script>
(function(){
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
var nameEl = form.querySelector('input[name="name"]');
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
// In commander field, Enter picks the first candidate (if any) without closing the modal
var cmdEl = form.querySelector('input[name=\"commander\"]');
if (cmdEl){
function handleEnterNav(e){
// Enter selects the highlighted (or first) suggestion
var list = document.getElementById('newdeck-candidates');
var btns = list ? Array.prototype.slice.call(list.querySelectorAll('button.candidate-btn')) : [];
var getActiveIndex = function(){ return btns.findIndex(function(b){ return b.classList.contains('active'); }); };
if (!btns.length) return; // nothing to do, but we've already prevented default
// Skip if a request is in-flight to avoid fighting with swap timing
try{ if (cmdEl.matches('.htmx-request') || list.matches('.htmx-request')) return; }catch(_){ }
if (e.key === 'Enter'){
var idx = getActiveIndex();
var target = btns[(idx >= 0 ? idx : 0)];
if (target) { target.click(); }
}
}
// Capture keydown early to prevent submit on Enter (arrows left to default behavior)
cmdEl.addEventListener('keydown', function(e){
if (e.key === 'Enter'){
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (hasBtns){
e.preventDefault();
e.stopPropagation();
handleEnterNav(e);
}
}
}, true);
// Defensive: also block Enter on keyup (in case a browser tries to submit on keyup)
cmdEl.addEventListener('keyup', function(e){ if (e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); } });
// Global fallback: capture keydown at the document level so Enter never slips through when the commander input is focused
document.addEventListener('keydown', function(e){
try{
if (document.activeElement !== cmdEl) return;
if (e.key !== 'Enter') return;
var list = document.getElementById('newdeck-candidates');
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
if (!hasBtns) return;
e.preventDefault();
e.stopPropagation();
handleEnterNav(e);
}catch(_){ }
}, true);
// Reset candidate highlight when the list updates
document.body.addEventListener('htmx:afterSwap', function(ev){
try {
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
if (!tgt) return;
if (tgt.id === 'newdeck-candidates'){
var first = tgt.querySelector('button.candidate-btn');
if (first){
// Clear any lingering active classes, then set the first as active for immediate Enter selection
tgt.querySelectorAll('button.candidate-btn').forEach(function(b){ b.classList.remove('active'); b.setAttribute('aria-selected','false'); });
first.classList.add('active');
first.setAttribute('aria-selected','true');
try{ cmdEl.setAttribute('aria-activedescendant', first.id || ''); }catch(_){ }
}
}
} catch(_){}
});
}
}
} catch(_){ }
// Close on Escape
function closeModal(){ try{ var m = document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){} }
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
document.addEventListener('keydown', onKey);
})();
</script>

View file

@ -0,0 +1,105 @@
{% set pname = commander.name %}
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ pname|urlencode }}&format=image&version=normal" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
</a>
</aside>
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
<script>
try {
var nm = document.querySelector('input[name="name"]');
var value = document.querySelector('#newdeck-commander-slot [data-card-name]')?.getAttribute('data-card-name') || '{{ pname|e }}';
if (nm && (!nm.value || !nm.value.trim())) { nm.value = value; }
} catch(_) {}
</script>
</div>
<div>
{% if tags and tags|length %}
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
<span class="muted" style="font-size:12px;">Combine</span>
<div role="group" aria-label="Combine mode">
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
<input type="radio" name="combine_mode_radio" value="AND" checked /> AND
</label>
<label title="OR treats your themes as a union (broader pool, fills easier).">
<input type="radio" name="combine_mode_radio" value="OR" /> OR
</label>
</div>
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
</div>
{% if recommended and recommended|length %}
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div>
</div>
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
{% endfor %}
</div>
{% else %}
<p class="muted">No theme tags available for this commander.</p>
{% endif %}
<!-- hidden inputs that the main modal form will submit -->
<input type="hidden" name="primary_tag" id="modal_primary_tag" />
<input type="hidden" name="secondary_tag" id="modal_secondary_tag" />
<input type="hidden" name="tertiary_tag" id="modal_tertiary_tag" />
<input type="hidden" name="tag_mode" id="modal_tag_mode" value="AND" />
<div id="modal-selected-themes" class="muted" style="font-size:12px; margin-top:.5rem;">
<em>No themes selected yet.</em>
</div>
</div>
<script>
(function(){
var list = document.getElementById('modal-tag-list');
var reco = document.getElementById('modal-tag-reco');
var selAll = document.getElementById('modal-reco-select-all');
var resetBtn = document.getElementById('modal-reset-tags');
var p = document.getElementById('modal_primary_tag');
var s = document.getElementById('modal_secondary_tag');
var t = document.getElementById('modal_tertiary_tag');
var mode = document.getElementById('modal_tag_mode');
var countEl = document.getElementById('modal-tag-count');
var selSummary = document.getElementById('modal-selected-themes');
if (!list) return;
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
function toggle(tag){ var cur=getSel(); var i=cur.indexOf(tag); if(i>=0){cur.splice(i,1);} else { if(cur.length>=3){cur=cur.slice(1);} cur.push(tag);} setSel(cur); }
function updateUI(){
try{ if(countEl) countEl.textContent = getSel().length + ' / 3 selected'; }catch(_){ }
try{
if(selSummary){
var sel = getSel();
if(!sel.length){ selSummary.innerHTML = '<em>No themes selected yet.</em>'; }
else {
var parts = [];
sel.forEach(function(tag, idx){ parts.push((idx+1) + '. ' + tag); });
selSummary.textContent = 'Selected: ' + parts.join(' · ');
}
}
}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);
}
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); }); });
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
updateUI();
})();
</script>

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 2 %}{% set step_total = 5 %}
<h3>Step 2: Tags & Bracket</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander.name }}">
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,7 @@
</a>
</aside>
<div class="grow" data-skeleton>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
<div hx-get="/build/banner" hx-trigger="load"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="commander" value="{{ commander.name }}" />
@ -95,7 +93,7 @@
</form>
<div style="margin-top:.5rem;">
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>
@ -116,6 +114,7 @@
var countEl = document.getElementById('tag-count');
var orderEl = document.getElementById('tag-order');
var commander = '{{ commander.name|e }}';
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
if (!chipHost) return;
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
@ -158,6 +157,13 @@
}
function loadPersisted(){
try {
// If this page load follows a fresh commander confirmation, wipe persisted values.
if (clearPersisted){
try {
localStorage.removeItem(storageKey('tags'));
localStorage.removeItem(storageKey('mode'));
} catch(_){ }
}
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 3 %}{% set step_total = 5 %}
<h3>Step 3: Ideal Counts</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,7 @@
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></div>
@ -37,7 +35,7 @@
</div>
</form>
<div style="margin-top:.5rem;">
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>

View file

@ -1,6 +1,5 @@
<section>
{% set step_index = 4 %}{% set step_total = 5 %}
<h3>Step 4: Review</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
@ -8,8 +7,12 @@
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></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>
</div>
{% endif %}
<h4>Chosen Ideals</h4>
<ul>
{% for key, label in labels.items() %}
@ -27,12 +30,13 @@
</label>
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
</form>
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
</form>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<form action="/build" method="get" style="display:inline; margin:0;">
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>

View file

@ -1,10 +1,11 @@
<section>
{% set step_index = 5 %}{% set step_total = 5 %}
<h3>Step 5: Build</h3>
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
sizes="(max-width: 900px) 100vw, 320px" />
</a>
{% if status and status.startswith('Build complete') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
@ -24,8 +25,7 @@
{% endif %}
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner" hx-trigger="load"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -48,6 +48,10 @@
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
</div>
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
{% set pct_clamped = (pct if pct <= 100 else 100) %}
@ -62,6 +66,30 @@
</div>
{% endif %}
{% if locked_cards is defined and locked_cards %}
<details id="locked-section" style="margin-top:.5rem;">
<summary>Locked cards (always kept)</summary>
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
{% for lk in locked_cards %}
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
<input type="hidden" name="name" value="{{ lk.name }}" />
<input type="hidden" name="locked" value="0" />
<input type="hidden" name="from_list" value="1" />
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<!-- Last action chip (oob-updated) -->
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
<!-- Filters toolbar -->
<div class="cards-toolbar">
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
@ -94,9 +122,9 @@
<!-- 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;">
<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('Starting build…'); }catch(_){}">
<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">Start Build</button>
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
@ -106,6 +134,23 @@
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
</form>
<span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle">
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
Replace stage picks
</label>
</form>
</div>
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
</form>
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
</form>
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
@ -115,6 +160,23 @@
</div>
{% if added_cards is not none %}
{% if history is defined and history %}
<details style="margin-top:.5rem;">
<summary>Stage timeline</summary>
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
{% for h in history %}
<li style="display:flex; align-items:center; gap:.5rem;">
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<input type="hidden" name="to" value="{{ h.i }}" />
<button type="submit" class="btn">Go</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<h4 style="margin-top:1rem;">Cards added this stage</h4>
{% if skipped and (not added_cards or added_cards|length == 0) %}
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
@ -127,6 +189,7 @@
{% if stage_label and stage_label.startswith('Creatures') %}
{% set groups = added_cards|groupby('sub_role') %}
{% for g in groups %}
{% set group_idx = loop.index0 %}
{% set role = g.grouper %}
{% if role %}
{% set heading = 'Theme: ' + role.title() %}
@ -139,46 +202,81 @@
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
</div>
<div class="card-grid group-grid" data-skeleton>
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
{% for c in g.list %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
</a>
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/&quot;/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
sizes="160px" />
</button>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
</div>
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card-grid" data-skeleton>
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
{% for c in added_cards %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
</a>
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/&quot;/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
sizes="160px" />
</button>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
</div>
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Click a card to lock or unlock it. Locked cards are kept across reruns and wont be replaced unless you unlock them.</div>
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
No cards match your filters.
</div>
@ -201,3 +299,67 @@
</div>
</div>
</section>
<script>
// Sync tile class and image-button toggle after lock button swaps
document.addEventListener('htmx:afterSwap', function(ev){
try{
const tgt = ev.target;
if(!tgt) return;
// Only act for lock-box updates
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
const tile = tgt.closest('.card-tile');
if(!tile) return;
const lockBtn = tgt.querySelector('.btn-lock');
if(lockBtn){
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
tile.classList.toggle('locked', isLocked);
const imgBtn = tile.querySelector('.img-btn');
if(imgBtn){
try{
const valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
const cur = JSON.parse(valsAttr.replace(/&quot;/g, '"'));
const next = isLocked ? '0' : '1';
// Keep name stable; fallback to tile data attribute
const nm = cur.name || tile.getAttribute('data-card-name') || '';
imgBtn.setAttribute('hx-vals', JSON.stringify({ name: nm, locked: next }));
imgBtn.title = 'Click to ' + (isLocked ? 'unlock' : 'lock') + ' this card';
try { imgBtn.setAttribute('aria-pressed', isLocked ? 'true' : 'false'); } catch(_){ }
}catch(_){/* noop */}
}
}
}catch(_){/* noop */}
});
// Allow dismissing/auto-clearing the last-action chip
document.addEventListener('click', function(ev){
try{
var t = ev.target;
if (!t) return;
if (t.matches && t.matches('#last-action .chip')){
var c = document.getElementById('last-action');
if (c) c.innerHTML = '';
}
}catch(_){/* noop */}
});
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
document.addEventListener('keydown', function(e){
try{
if (e.ctrlKey || e.metaKey || e.altKey) return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
// Ignore when typing in inputs/selects
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
if (!tile) return;
if (e.key === 'l' || e.key === 'L') {
e.preventDefault(); e.stopPropagation();
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
if (lockFormBtn) { lockFormBtn.click(); }
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault(); e.stopPropagation();
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
if (altBtn) { altBtn.click(); }
}
}catch(_){ }
});
</script>

View file

@ -2,24 +2,12 @@
{% block banner_subtitle %}Build a Deck{% endblock %}
{% block content %}
<h2>Build a Deck</h2>
<div style="margin:.25rem 0 1rem 0;">
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
</div>
<div id="wizard">
{% set step = last_step or 1 %}
{% if step == 1 %}
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
{% elif step == 2 %}
<div hx-get="/build/step2" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=2&n=5" hx-trigger="load"></div>
{% elif step == 3 %}
<div hx-get="/build/step3" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=3&n=5" hx-trigger="load"></div>
{% elif step == 4 %}
<div hx-get="/build/step4" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=4&n=5" hx-trigger="load"></div>
{% else %}
<div hx-get="/build/step5" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=5&n=5" hx-trigger="load"></div>
{% endif %}
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
<!-- Wizard content will load here after the modal submit starts the build. -->
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
</div>
{% endblock %}

View file

@ -39,7 +39,7 @@
{% if summary %}
{% include "partials/deck_summary.html" %}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% endif %}
{% endif %}

View file

@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block banner_subtitle %}Compare Decks{% endblock %}
{% block content %}
<h2>Compare Decks</h2>
<p class="muted">Pick two finished decks to compare. You can get here from Finished Decks or deck view pages.</p>
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label>Deck A
<select name="A" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<label>Deck B
<select name="B" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Compare</button>
<button type="button" id="cmp-swap" class="btn" title="Swap A and B" style="margin-left:.25rem;">Swap A/B</button>
<button type="button" id="cmp-latest" class="btn" title="Pick the latest two decks">Latest two</button>
</form>
{% if diffs %}
<div class="panel" style="margin-top:.75rem;">
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:center;">
<div>
<strong>A:</strong> {{ metaA.display or metaA.filename }}
{% if metaA.commander %}<span class="muted">({{ metaA.commander }})</span>{% endif %}
{% if metaA.tags %}<div class="muted">{{ metaA.tags }}</div>{% endif %}
</div>
<div>
<strong>B:</strong> {{ metaB.display or metaB.filename }}
{% if metaB.commander %}<span class="muted">({{ metaB.commander }})</span>{% endif %}
{% if metaB.tags %}<div class="muted">{{ metaB.tags }}</div>{% endif %}
</div>
<div style="margin-left:auto; display:flex; gap:.5rem; align-items:center;">
<button type="button" id="cmp-copy" class="btn" title="Copy a plain-text summary of the diffs">Copy summary</button>
<button type="button" id="cmp-download" class="btn" title="Download a plain-text summary of the diffs">Download .txt</button>
</div>
</div>
</div>
<div class="panel" style="margin-top:.5rem; display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<div class="muted">Totals:
<strong id="totA">{{ (diffs.onlyA|length) if diffs.onlyA else 0 }}</strong> only-in-A,
<strong id="totB">{{ (diffs.onlyB|length) if diffs.onlyB else 0 }}</strong> only-in-B,
<strong id="totC">{{ (diffs.changed|length) if diffs.changed else 0 }}</strong> changed
</div>
<label class="muted" style="margin-left:auto; display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" id="cmp-changed-only" /> Changed only
</label>
</div>
<div class="only-panels" style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top:.75rem;">
<div class="panel onlyA">
<h3 style="margin-top:0;">Only in A</h3>
{% if diffs.onlyA and diffs.onlyA|length %}
<ul>
{% for n in diffs.onlyA %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<div class="panel onlyB">
<h3 style="margin-top:0;">Only in B</h3>
{% if diffs.onlyB and diffs.onlyB|length %}
<ul>
{% for n in diffs.onlyB %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
</div>
<div class="panel" style="margin-top:1rem;">
<h3 style="margin-top:0;">Changed counts</h3>
{% if diffs.changed and diffs.changed|length %}
<ul class="changed-list">
{% for n, a, b in diffs.changed %}
{% set delta = b - a %}
{% if delta > 0 %}
<li class="chg inc" title="Increased in B">▲ {{ n }}: A={{ a }}, B={{ b }} (+{{ delta }})</li>
{% elif delta < 0 %}
<li class="chg dec" title="Decreased in B">▼ {{ n }}: A={{ a }}, B={{ b }} ({{ delta }})</li>
{% else %}
<li class="chg">{{ n }}: A={{ a }}, B={{ b }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<script id="cmp-data" type="application/json">{{ {
'aLabel': (metaA.display or metaA.filename),
'bLabel': (metaB.display or metaB.filename),
'onlyA': diffs.onlyA or [],
'onlyB': diffs.onlyB or [],
'changed': diffs.changed or []
} | tojson }}</script>
<script>
(function(){
var copyBtn = document.getElementById('cmp-copy');
var dlBtn = document.getElementById('cmp-download');
var changedOnly = document.getElementById('cmp-changed-only');
var dataEl = document.getElementById('cmp-data');
var data = null;
try { data = JSON.parse((dataEl && dataEl.textContent) ? dataEl.textContent : 'null'); } catch(e) { data = null; }
function buildLines(){
var lines = [];
lines.push('Compare:');
lines.push('A: ' + data.aLabel);
lines.push('B: ' + data.bLabel);
lines.push('');
if (!changedOnly || !changedOnly.checked) {
lines.push('Only in A:');
if (data.onlyA && data.onlyA.length) { data.onlyA.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
lines.push('Only in B:');
if (data.onlyB && data.onlyB.length) { data.onlyB.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
}
lines.push('Changed counts:');
if (data.changed && data.changed.length) {
data.changed.forEach(function(row){ lines.push('- ' + row[0] + ': A=' + row[1] + ', B=' + row[2]); });
} else { lines.push('(none)'); }
return lines;
}
if (copyBtn) copyBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt); }
else {
var ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); }catch(_){} document.body.removeChild(ta);
}
if (window.toast) window.toast('Copied comparison');
}catch(_){ }
});
if (dlBtn) dlBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
var blob = new Blob([txt], {type:'text/plain'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'compare.txt';
document.body.appendChild(a);
a.click();
setTimeout(function(){ try{ URL.revokeObjectURL(url); document.body.removeChild(a); }catch(_){ } }, 0);
}catch(_){ }
});
function applyChangedOnlyFlag(){
try{
var wrap = document.querySelector('.only-panels');
if (!wrap || !changedOnly) return;
wrap.style.display = changedOnly.checked ? 'none' : 'grid';
}catch(_){ }
}
if (changedOnly) {
try {
var saved = localStorage.getItem('compare:changedOnly');
if (saved === '1') { changedOnly.checked = true; }
} catch(_){ }
applyChangedOnlyFlag();
changedOnly.addEventListener('change', function(){
try { localStorage.setItem('compare:changedOnly', this.checked ? '1' : '0'); } catch(_){ }
applyChangedOnlyFlag();
});
}
// Swap A/B
var swapBtn = document.getElementById('cmp-swap');
if (swapBtn) swapBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var aVal = a.value, bVal = b.value;
a.value = bVal; b.value = aVal;
f.requestSubmit();
}catch(_){ }
});
// Pick latest two by mtime from options metadata
var latestBtn = document.getElementById('cmp-latest');
if (latestBtn) latestBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var opts = Array.from(a.querySelectorAll('option[value]')).filter(function(o){ return o.value; });
opts.sort(function(x,y){
var mx = parseInt(x.getAttribute('data-mtime') || '0', 10);
var my = parseInt(y.getAttribute('data-mtime') || '0', 10);
return (my - mx);
});
if (opts.length >= 2){
var first = opts[0].value;
var second = opts[1].value;
a.value = first; b.value = second;
f.requestSubmit();
}
}catch(_){ }
});
})();
</script>
<style>
.changed-list { list-style: none; padding-left: 0; }
.changed-list .chg { padding: 2px 0; }
.changed-list .chg.inc { color: #10b981; }
.changed-list .chg.dec { color: #ef4444; }
.only-panels .onlyA h3 { color: #60a5fa; }
.only-panels .onlyB h3 { color: #f59e0b; }
</style>
{% endif %}
{% endblock %}

View file

@ -24,6 +24,10 @@
</label>
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
<span id="deck-count" class="muted" aria-live="polite"></span>
@ -38,7 +42,12 @@
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
<div>
<div>
{% if it.display %}
<strong>{{ it.display }}</strong>
<div class="muted" style="font-size:12px;">Commander: <span data-card-name="{{ it.commander }}">{{ it.commander }}</span></div>
{% else %}
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
{% endif %}
</div>
{% if it.tags and it.tags|length %}
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
@ -50,6 +59,10 @@
</div>
</div>
<div style="display:flex; gap:.35rem; align-items:center;">
<label title="Select deck for comparison" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
<span class="muted" style="font-size:12px;">Select</span>
</label>
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.path }}" />
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
@ -112,11 +125,22 @@
var helpClose = document.getElementById('deck-help-close');
var helpBackdrop = document.getElementById('deck-help-backdrop');
var txtOnlyCb = document.getElementById('deck-txt-only');
var cmpSelBtn = document.getElementById('deck-compare-selected');
var cmpLatestBtn = document.getElementById('deck-compare-latest');
var openPermalinkBtn = document.getElementById('deck-open-permalink');
if (!list) return;
// Panels and themes discovery from data-tags-pipe
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
// Selection state for compare
var selected = new Set();
function updateCompareButtons(){
if (!cmpSelBtn) return;
var size = selected.size;
cmpSelBtn.disabled = (size !== 2);
if (cmpSelBtn) cmpSelBtn.title = (size === 2 ? 'Compare the two selected decks' : 'Select exactly two decks to enable');
}
var themeSet = new Set();
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
@ -309,6 +333,31 @@
updateHashFromState();
}
// Wire up compare selection checkboxes
function attachSelectHandlers(){
try {
var cbs = Array.prototype.slice.call(list.querySelectorAll('input.deck-select'));
cbs.forEach(function(cb){
// Initialize checked state based on current selection
var row = cb.closest('.panel');
var name = row ? (row.dataset.name || '') : '';
cb.checked = selected.has(name);
// Apply visual state on init
if (row) row.classList.toggle('selected', cb.checked);
cb.addEventListener('change', function(){
if (!name) return;
if (cb.checked) { selected.add(name); }
else { selected.delete(name); }
// Toggle selection highlight
if (row) row.classList.toggle('selected', cb.checked);
updateCompareButtons();
});
});
updateCompareButtons();
} catch(_){}
}
attachSelectHandlers();
// Debounce helper
function debounce(fn, delay){
var timer = null;
@ -332,6 +381,53 @@
applyAll();
});
// Compare selected action
if (cmpSelBtn) cmpSelBtn.addEventListener('click', function(){
try {
if (selected.size !== 2) return;
var arr = Array.from(selected);
var url = '/decks/compare?A=' + encodeURIComponent(arr[0]) + '&B=' + encodeURIComponent(arr[1]);
window.location.href = url;
} catch(_){ }
});
// Latest two (by modified time across all decks, not just visible)
if (cmpLatestBtn) cmpLatestBtn.addEventListener('click', function(){
try {
// Gather all panels (including hidden) and sort by data-mtime desc
var rows = Array.prototype.slice.call(list.querySelectorAll('.panel'));
rows.sort(function(a,b){
var am = parseFloat(a.dataset.mtime || '0');
var bm = parseFloat(b.dataset.mtime || '0');
return bm - am;
});
// Take first two distinct names
var pick = [];
for (var i=0; i<rows.length && pick.length<2; i++){
var nm = rows[i].dataset.name || '';
if (nm && pick.indexOf(nm) === -1) pick.push(nm);
}
if (pick.length === 2){
var url = '/decks/compare?A=' + encodeURIComponent(pick[0]) + '&B=' + encodeURIComponent(pick[1]);
window.location.href = url;
} else {
if (window.toast) window.toast('Need at least two decks');
}
} catch(_){ }
});
// Open permalink prompt
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
try{
var token = prompt('Paste a /build/from?state=... URL or token:');
if(!token) return;
var m = token.match(/state=([^&]+)/);
var t = m ? m[1] : token.trim();
if(!t) return;
window.location.href = '/build/from?state=' + encodeURIComponent(t);
}catch(_){ }
});
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
// Clear UI state
try {
@ -408,6 +504,10 @@
// React to external hash changes
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
// Re-attach selection handlers when list changes order
var observer = new MutationObserver(function(){ attachSelectHandlers(); });
try { observer.observe(list, { childList: true }); } catch(_){ }
// Open deck: keyboard and mouse helpers on panels
function getPanelUrl(p){
try {
@ -551,5 +651,6 @@
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
#deck-list .panel.selected { box-shadow: 0 0 0 2px #10b981 inset; border-color: #10b981; }
</style>
{% endblock %}

View file

@ -2,6 +2,9 @@
{% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %}
<h2>Finished Deck</h2>
{% if display_name %}
<div><strong>{{ display_name }}</strong></div>
{% endif %}
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
@ -24,6 +27,7 @@
<button type="submit">Download TXT</button>
</form>
{% endif %}
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
<form method="get" action="/decks" style="display:inline; margin:0;">
<button type="submit">Back to Finished Decks</button>
</form>
@ -54,7 +58,7 @@
</div>
</div>
{% endif %}
{% include "partials/deck_summary.html" %}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% else %}
<div class="muted">No summary available.</div>
{% endif %}

View file

@ -7,6 +7,15 @@
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
</div>
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Performance (local)</h3>
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
<div style="display:flex; gap:1rem; flex-wrap:wrap">
<div><strong>Scroll FPS:</strong> <span id="perf-fps"></span></div>
<div><strong>Visible tiles:</strong> <span id="perf-visible"></span></div>
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div>
</div>
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
<h3 style="margin-top:0">Error triggers</h3>
<div class="row" style="display:flex; gap:.5rem; align-items:center">
@ -39,6 +48,40 @@
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
// Perf probe: listen to scroll on a card grid if present
try{
var fpsEl = document.getElementById('perf-fps');
var visEl = document.getElementById('perf-visible');
var rcEl = document.getElementById('perf-renders');
var grid = document.querySelector('.card-grid');
var last = performance.now();
var frames = 0; var renders = 0;
function tick(){
frames++;
var now = performance.now();
if (now - last >= 500){
var fps = Math.round((frames * 1000) / (now - last));
if (fpsEl) fpsEl.textContent = String(fps);
frames = 0; last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function updateVisible(){
try{
if (!grid) return;
var tiles = grid.querySelectorAll('.card-tile');
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
if (visEl) visEl.textContent = String(c);
}catch(_){ }
}
if (grid){
grid.addEventListener('scroll', updateVisible);
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
mo.observe(grid, { childList: true, subtree: true, attributes: false });
updateVisible();
}
}catch(_){ }
})();
</script>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Diagnostics: Synthetic Perf Probe</h2>
<p class="muted">Scroll the list; we estimate FPS and count re-renders. This page is only available when diagnostics are enabled.</p>
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin:.5rem 0 1rem 0;">
<div><strong>FPS:</strong> <span id="fps"></span></div>
<div><strong>Visible rows:</strong> <span id="rows"></span></div>
<div><strong>Render count:</strong> <span id="renders">0</span></div>
</div>
<div id="probe" style="height:60vh; overflow:auto; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
<ul id="list" style="list-style:none; margin:0; padding:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:8px 12px;">
{% for i in range(1,1201) %}
<li style="padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
<div style="display:flex; align-items:center; gap:.5rem;">
<div style="width:64px; height:40px; background:#111; border:1px solid var(--border); border-radius:6px;">
<img class="card-thumb" alt="Thumb {{ i }}" loading="lazy" decoding="async" data-lqip
src="https://api.scryfall.com/cards/named?fuzzy=Lightning%20Bolt&format=image&version=small"
width="64" height="40" style="width:64px; height:40px; object-fit:cover; border-radius:6px;" />
</div>
<div style="display:flex; flex-direction:column; gap:.25rem;">
<strong>Row {{ i }}</strong>
<small class="muted">Synthetic item for performance testing</small>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</section>
<script>
(function(){
var probe = document.getElementById('probe');
var list = document.getElementById('list');
var fpsEl = document.getElementById('fps');
var rowsEl = document.getElementById('rows');
var rcEl = document.getElementById('renders');
var last = performance.now();
var frames = 0; var renders = 0;
function raf(){
frames++;
var now = performance.now();
if (now - last >= 500){
var fps = Math.round((frames * 1000) / (now - last));
if (fpsEl) fpsEl.textContent = String(fps);
frames = 0; last = now;
}
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
function updateVisible(){
if (!probe || !list) return;
var count = 0;
list.querySelectorAll('li').forEach(function(li){
// rough: count if within viewport
var rect = li.getBoundingClientRect();
var pRect = probe.getBoundingClientRect();
if (rect.bottom >= pRect.top && rect.top <= pRect.bottom) count++;
});
if (rowsEl) rowsEl.textContent = String(count);
}
if (probe){
probe.addEventListener('scroll', updateVisible);
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
mo.observe(list, { childList: true, subtree: true });
updateVisible();
}
})();
</script>
{% endblock %}

View file

@ -70,7 +70,7 @@
{% endif %}
{% if names and names|length %}
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;">
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize %}data-virtualize="1"{% endif %}>
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
{% for n in names %}
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
@ -81,7 +81,9 @@
<label class="owned-row" style="cursor:pointer;" tabindex="0">
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
<div class="owned-vstack">
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
sizes="100px" />
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% if cols and cols|length %}
<div class="mana-group" aria-hidden="true">

View file

@ -81,7 +81,9 @@
{% set cnt = c.count if c.count else 1 %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" />
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
</div>
@ -561,6 +563,12 @@
if (!on && !match) tile.classList.remove('chart-highlight');
});
} catch(_) {}
// If virtualized lists are enabled, auto-scroll the Step 5 grid to the first match
try {
if (on && window.scrollCardIntoView && Array.isArray(names) && names.length) {
window.scrollCardIntoView(names[0]);
}
} catch(_) {}
}
attach();
document.addEventListener('htmx:afterSwap', function() { attach(); });

View file

@ -38,6 +38,8 @@ services:
# Speed up setup/tagging in Web UI via parallel workers
- WEB_TAG_PARALLEL=1
- WEB_TAG_WORKERS=4
# Enable virtualization + lazy image tweaks in Step 5
- WEB_VIRTUALIZE=1
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs

View file

@ -14,13 +14,14 @@ if not exist "owned_cards" mkdir owned_cards
REM Flags (override by setting env vars before running)
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
echo Starting Web UI on http://localhost:8080
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS%
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% WEB_VIRTUALIZE=%WEB_VIRTUALIZE%
docker run --rm ^
-p 8080:8080 ^
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% ^
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% -e WEB_VIRTUALIZE=%WEB_VIRTUALIZE% ^
-v "%cd%\deck_files:/app/deck_files" ^
-v "%cd%\logs:/app/logs" ^
-v "%cd%\csv_files:/app/csv_files" ^