Merge pull request #5 from mwisnowski/implement-ui

Release v2.0.1 – Web UI upgrade, theme reset, diagnostics, docs
This commit is contained in:
mwisnowski 2025-08-28 16:47:22 -07:00 committed by GitHub
commit 9357a04541
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 12566 additions and 246 deletions

6
.gitignore vendored
View file

@ -4,13 +4,13 @@
*.txt
.mypy_cache/
.venv/
.pytest_cache/
test.py
main.spec
!requirements.txt
__pycache__/
build/
csv_files/
dist/
logs/
!config/deck.json
RELEASE_NOTES.md
RELEASE_NOTES.md
*.bkp

View file

@ -12,7 +12,24 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
## [2.0.1] - 2025-08-28
### 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
- Owned-cards workflow:
- Prompt (only if lists exist) to "Use only owned cards?"
- Support multiple file selection; parse `.txt` (1 per line) and `.csv` (any `name` column)
@ -20,16 +37,63 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Recommendations export when owned-only deck is incomplete (~1.5× missing) to `deck_files/[stem]_recommendations.csv` and `.txt`
- CSV export includes an `Owned` column when not using owned-only
- Windows EXE build via PyInstaller is produced on tag and attached to GitHub Releases
- Prefer-owned option in Review: bias selection toward owned cards while allowing unowned fallback (stable reorder + gentle weight boosts applied across creatures and spells)
- Owned page enhancements: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling
- Finished Decks: theme filters converted to a dropdown with shareable state
- Staged build: optional "Show skipped stages" toggle to surface stages that added no cards with a clear annotation
- Owned/Not-owned badges visible across views; consolidated CSS for consistent placement
- Visual summaries: Mana Curve, Color Pips and Sources charts with cross-highlighting to cards; tooltips show per-color card lists and include a Copy action
- Source detection: include non-land mana producers and colorless 'C'; basic lands reliably counted; fetch lands excluded as sources
- Favicon support: `/favicon.ico` served (ICO with PNG fallback)
- Diagnostics: `/healthz` endpoint returns `{status, version, uptime_seconds}`; responses carry `X-Request-ID`; unhandled errors return JSON with request_id
- Diagnostics page and tools gated by `SHOW_DIAGNOSTICS`; Logs page gated by `SHOW_LOGS`; both off by default
- Global error handling: friendly HTML templates for 404/4xx/500 with Request-ID and "Go home" link; JSON structure for HTMX/API
- Request-ID middleware assigns `X-Request-ID` to all responses and includes it in JSON error payloads
- `/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
- Theme UX: Header includes a Reset Theme control to clear browser preference and reapply server default (THEME) or system mapping. Diagnostics page shows resolved theme and stored preference with a reset action.
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`
- Compose and helper scripts updated accordingly
- Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored
- README/DOCKER/WINDOWS_DOCKER_GUIDE updated for Web UI, headless examples, and PowerShell-friendly commands
- Headless: tag_mode (AND/OR) accepted from JSON and environment and exported in interactive run-config JSON
- Owned lists are enriched at upload-time and persisted in an internal store; header rows skipped and duplicates deduped; per-request parsing removed
- 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)
- Owned page UX: full-size preview now pops on thumbnail hover (not the name); selection highlight tightened to the thumbnail only and changed to white for better contrast; Themes in the hover popout render as a larger bullet list with a brighter "THEMES" label
- Image robustness: standardized `data-card-name` on all Scryfall images and centralized retry logic (thumbnails + previews) with version fallbacks (small/normal/large) and a single cache-bust refresh on final failure; removed the previous hover-image cache to reduce complexity and overhead
- Layout polish: fixed sidebar remains full-height under the banner with a subtle right-edge shadow for depth; grid updated to prevent content squish; extra scroll removed; footer pinned when content is short.
- Deck Summary list view: rows use fixed tracks for count, ×, name, and owned columns (monospace tabular numerals) to ensure perfect alignment; highlight is an inset box-shadow on the name to avoid layout shifts; long names ellipsize with a tooltip; list starts directly under the type header and remains stable on full-screen widths
### Fixed
- Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest`
- Owned page internal server error resolved via hardened template context and centralized owned context builder
- Web container crash resolved by removing invalid union type annotation in favicon route; route now returns a single Response type
- Source highlighting consistency: charts now correctly cross-highlight corresponding cards in both list and thumbnail views
- 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)
---

127
DOCKER.md
View file

@ -1,6 +1,6 @@
# Docker Guide (concise)
# Docker Guide
Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode.
Run the MTG Deckbuilder (CLI and Web UI) in Docker with persistent volumes and optional headless mode.
## Quick start
@ -21,6 +21,112 @@ docker run -it --rm `
mwisnowski/mtg-python-deckbuilder:latest
```
## Web UI (new)
The web UI runs the same deckbuilding logic behind a browser-based interface.
### PowerShell (recommended)
```powershell
docker compose up --build --no-deps -d web
```
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. The sidebar has a subtle depth shadow for clearer separation.
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.
- `SHOW_DIAGNOSTICS=1` — adds a Diagnostics nav link and `/diagnostics` tools
- `SHOW_LOGS=1` — enables `/logs` and `/status/logs?tail=200`
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
environment:
- SHOW_LOGS=1
- SHOW_DIAGNOSTICS=1
```
Docker Hub (PowerShell) example:
```powershell
docker run --rm `
-p 8080:8080 `
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 -e ENABLE_THEMES=1 -e THEME=system `
-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" `
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
```
### Setup speed: parallel tagging (Web)
First-time setup or stale data triggers card tagging. The web service uses parallel workers by default.
Configure via environment variables on the `web` service:
- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging (default: 1)
- `WEB_TAG_WORKERS=<N>` — number of worker processes (default: 4 in compose)
If parallel initialization fails, the service falls back to sequential tagging and continues.
### From Docker Hub (PowerShell)
If you prefer not to build locally, pull `mwisnowski/mtg-python-deckbuilder:latest` and run uvicorn:
```powershell
docker run --rm `
-p 8080:8080 `
-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" `
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
```
Health check:
```text
GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "uptime_seconds": 123 }
```
Theme preference reset (client-side): use the headers Reset Theme control to clear the saved browser preference; the server default (THEME) applies on next paint.
## Volumes
- `/app/deck_files``./deck_files`
- `/app/logs``./logs`
@ -47,6 +153,13 @@ docker run -it --rm `
- DECK_CONFIG=/app/config/deck.json
- DECK_COMMANDER, DECK_PRIMARY_CHOICE
- DECK_ADD_LANDS, DECK_FETCH_COUNT
- DECK_TAG_MODE=AND|OR (combine mode used by the builder)
### 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
@ -60,11 +173,11 @@ docker run -it --rm `
mtg-deckbuilder
```
## Troubleshooting
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
- Files not saving? Verify volume mounts and that folders exist
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
## Troubleshooting
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
- Files not saving? Verify volume mounts and that folders exist
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
## Tips
- Use `docker compose run`, not `up`, for interactive mode

View file

@ -48,3 +48,10 @@ WORKDIR /app/code
# Run the application
CMD ["python", "main.py"]
# Expose web port for the optional Web UI
EXPOSE 8080
# 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

@ -1,28 +1,66 @@
# MTG Python Deckbuilder ${VERSION}
## Highlights
- Owned cards: prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`.
- Owned-only builds filter the pool by your lists; if the deck can't reach 100, it remains incomplete and notes it.
- Recommendations: on incomplete owned-only builds, exports `deck_files/[stem]_recommendations.csv` and `.txt` with ~1.5× missing cards, and prints a short notice.
- Owned column: when not using owned-only, owned cards are marked with an `Owned` column in the final CSV.
- Headless support: run non-interactively or via the menu's headless submenu.
- Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored.
- Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`.
- Power bracket: set interactively or via `bracket_level` (env: `DECK_BRACKET_LEVEL`).
- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
- Docker: mount `./owned_cards` to `/app/owned_cards` to enable owned-cards features; `./config` to `/app/config` for JSON configs.
- New Web UI: FastAPI + Jinja front-end with a staged build view and clear reasons per stage. Step 2 now includes AND/OR combine mode with tooltips and selection-order display. Footer includes Scryfall attribution per their guidelines.
- AND/OR combine mode: OR (default) recommends across any selected themes with overlap preference; AND prioritizes multi-theme intersections. In creatures, an AND pre-pass selects "all selected themes" creatures first, then fills by weighted overlap. Staged reasons show which selected themes each all-theme creature hits.
- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config.
- Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV.
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
- Theming: Consolidated Light (Blend) as the only light palette; default THEME can be system|light|dark. Header includes a Reset Theme control to clear saved preference; diagnostics shows resolved theme and stored preference.
- 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.
- Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads.
- Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation.
- 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 }`.
- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation.
- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests).
- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`.
- Diagnostics page surfaces resolved theme and preference, with a Reset preference action.
## Whats new
- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel.
- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode.
- Reporting: deck summary includes per-color card lists for Pips and Sources; colorless 'C' surfaced and totals corrected.
- UI Polish: list-mode highlight wraps only the card name. Chart tooltips include a Copy action with hover persistence.
- Exports: CSV gains fallback oracle text for basic lands (Plains/Island/Swamp/Mountain/Forest/Wastes) when missing.
- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`).
- Prefer-owned bias across creatures and spells selections; Review step includes a toggle next to the owned-only control.
- Owned page features and performance improvements via upload-time enrichment and persistence.
- Staged build UI can surface skipped stages when enabled.
## Docker
- Single service; persistent volumes:
- CLI and Web UI in the same image.
- docker-compose includes a `web` service exposing port 8080 by default.
- Persistent volumes:
- /app/deck_files
- /app/logs
- /app/csv_files
- /app/owned_cards
- /app/config (mount `./config` for JSON configs)
- /app/config
### Web UI performance tuning
- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging during initial setup/tagging in the Web UI
- `WEB_TAG_WORKERS=<N>` — number of worker processes (omit to auto-pick; compose default: 4)
### Quick Start
```powershell
# From Docker Hub
# CLI from Docker Hub
docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
@ -31,30 +69,49 @@ docker run -it --rm `
-v "${PWD}/config:/app/config" `
mwisnowski/mtg-python-deckbuilder:latest
# From source with Compose
# Web UI from Docker Hub
docker run --rm `
-p 8080:8080 `
-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" `
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
# From source with Compose (CLI)
docker compose build
docker compose run --rm mtg-deckbuilder
# Headless (optional)
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
# With JSON config
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
# From source with Compose (Web)
docker compose build web
docker compose up --no-deps web
```
## Changes
- Added owned-cards workflow, CSV Owned column, and recommendations export when owned-only builds are incomplete.
- Docker assets updated to include `/app/owned_cards` volume and mount examples.
- Windows release workflow now attaches a PyInstaller-built EXE to GitHub Releases.
- Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer.
- Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode.
- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON.
- Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples.
- Docker: compose `web` service added; volumes clarified.
- Visual summaries and diagnostics: added `/healthz` endpoint with version/uptime and request-id propagation on all responses.
- Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes.
- Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building.
- 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: fixed a pattern issue by treating "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
- 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.
- Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood.
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched.
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters; Energy enriched.
- Spawn/Scion creators now map to Aristocrats and Ramp.
## Known Issues
- First run downloads card data (takes a few minutes)
- Use `docker compose run --rm` (not `up`) for interactive sessions
- Use `docker compose run --rm` (not `up`) for interactive CLI sessions
- Ensure volumes are mounted to persist files outside the container
## Links

View file

@ -41,6 +41,25 @@ docker run -it --rm `
mwisnowski/mtg-python-deckbuilder:latest
```
### Optional: Web UI from Docker Hub
Run the browser UI by mapping a port and starting uvicorn:
```powershell
docker run --rm `
-p 8080:8080 `
-e WEB_VIRTUALIZE=1 ` # optional virtualization
-e ENABLE_THEMES=1 -e THEME=system ` # optional theme selector and default
-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" `
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
```
Then open http://localhost:8080
Tip: The header includes a Reset Theme control to clear your browsers saved preference and re-apply the servers default (or OS when THEME=system).
## Method 2: Command Prompt
```cmd
REM Create and navigate to workspace
@ -151,3 +170,7 @@ C:\mtg-decks\
├── deck_files\ # Your completed decks (.csv and .txt files)
│ ├── Atraxa_Superfriends_20250821.csv
│ ├── Atraxa_Superfriends_20250821.txt
├── logs\\
├── csv_files\\
├── owned_cards\\
└── config\\

View file

@ -228,6 +228,49 @@ class DeckBuilder(
if hasattr(super(), 'add_spells_phase'):
return super().add_spells_phase()
raise NotImplementedError("Spell addition phase not implemented.")
# ---------------------------
# Lightweight confirmations (CLI pauses; web auto-continues)
# ---------------------------
def _pause(self, message: str = "Press Enter to continue...") -> None:
try:
_ = self.input_func(message)
except Exception:
pass
def confirm_primary_theme(self) -> None:
if getattr(self, 'primary_tag', None):
self.output_func(f"Primary Theme: {self.primary_tag}")
self._pause()
def confirm_secondary_theme(self) -> None:
if getattr(self, 'secondary_tag', None):
self.output_func(f"Secondary Theme: {self.secondary_tag}")
self._pause()
def confirm_tertiary_theme(self) -> None:
if getattr(self, 'tertiary_tag', None):
self.output_func(f"Tertiary Theme: {self.tertiary_tag}")
self._pause()
def confirm_ramp_spells(self) -> None:
self.output_func("Confirm Ramp")
self._pause()
def confirm_removal_spells(self) -> None:
self.output_func("Confirm Removal")
self._pause()
def confirm_wipes_spells(self) -> None:
self.output_func("Confirm Board Wipes")
self._pause()
def confirm_card_advantage_spells(self) -> None:
self.output_func("Confirm Card Advantage")
self._pause()
def confirm_protection_spells(self) -> None:
self.output_func("Confirm Protection")
self._pause()
# Commander core selection state
commander_name: str = ""
commander_row: Optional[pd.Series] = None
@ -238,6 +281,8 @@ class DeckBuilder(
secondary_tag: Optional[str] = None
tertiary_tag: Optional[str] = None
selected_tags: List[str] = field(default_factory=list)
# How to combine multiple selected tags when prioritizing cards: 'AND' or 'OR'
tag_mode: str = 'AND'
# Future deck config placeholders
color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G']
@ -264,6 +309,8 @@ class DeckBuilder(
use_owned_only: bool = False
owned_card_names: set[str] = field(default_factory=set)
owned_files_selected: List[str] = field(default_factory=list)
# Soft preference: bias selection toward owned names without excluding others
prefer_owned: bool = False
# Deck library (cards added so far) mapping name->record
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
@ -941,6 +988,7 @@ class DeckBuilder(
self.output_func("Owned-only mode: no recognizable name column to filter on; skipping filter.")
except Exception as _e:
self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}")
# Soft prefer-owned does not filter the pool; biasing is applied later at selection time
self._combined_cards_df = combined
# Preserve original snapshot for enrichment across subsequent removals
if self._full_cards_df is None:
@ -1201,6 +1249,7 @@ class DeckBuilder(
'ramp': bc.DEFAULT_RAMP_COUNT,
'lands': bc.DEFAULT_LAND_COUNT,
'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT,
'fetch_lands': getattr(bc, 'FETCH_LAND_DEFAULT_COUNT', 3),
'creatures': bc.DEFAULT_CREATURE_COUNT,
'removal': bc.DEFAULT_REMOVAL_COUNT,
'wipes': bc.DEFAULT_WIPES_COUNT,
@ -1248,6 +1297,7 @@ class DeckBuilder(
('ramp', 'Ramp Pieces'),
('lands', 'Total Lands'),
('basic_lands', 'Minimum Basic Lands'),
('fetch_lands', 'Fetch Lands'),
('creatures', 'Creatures'),
('removal', 'Spot Removal'),
('wipes', 'Board Wipes'),
@ -1270,6 +1320,7 @@ class DeckBuilder(
('ramp', 'Ramp'),
('lands', 'Total Lands'),
('basic_lands', 'Basic Lands (Min)'),
('fetch_lands', 'Fetch Lands'),
('creatures', 'Creatures'),
('removal', 'Spot Removal'),
('wipes', 'Board Wipes'),

View file

@ -376,6 +376,7 @@ DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
'ramp': 'Enter desired number of ramp pieces (default: 8):',
'lands': 'Enter desired number of total lands (default: 35):',
'basic_lands': 'Enter minimum number of basic lands (default: 15):',
'fetch_lands': 'Enter desired number of fetch lands (default: 3):',
'creatures': 'Enter desired number of creatures (default: 25):',
'removal': 'Enter desired number of spot removal spells (default: 10):',
'wipes': 'Enter desired number of board wipes (default: 2):',

View file

@ -66,13 +66,15 @@ def normalize_theme_list(raw) -> list[str]:
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
can (reliably) produce each color.
"""Build a matrix mapping card name -> {color: 0/1} indicating if that card
can (reliably) produce each color of mana on the battlefield.
Heuristics:
- Presence of basic land types in type line grants that color.
- Text containing "add one mana of any color/colour" grants all colors.
- Explicit mana symbols in rules text (e.g. "{R}") grant that color.
Notes:
- Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana.
- Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst.
- Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately.
- For lands, we also infer from basic land types in the type line. For non-lands, we rely on text.
- Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes.
Parameters
----------
@ -89,29 +91,84 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if nm and nm not in lookup:
lookup[nm] = r
for name, entry in card_library.items():
if 'land' not in str(entry.get('Card Type', '')).lower():
continue
row = lookup.get(name, {})
tline = str(row.get('type', row.get('type_line', ''))).lower()
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
colors = {c: 0 for c in COLOR_LETTERS}
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field:
for k in colors:
entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower()
tline_full = str(row.get('type', row.get('type_line', '')) or '').lower()
# Land or permanent that could produce mana via text
is_land = ('land' in entry_type) or ('land' in tline_full)
text_field = str(row.get('text', row.get('oracleText', '')) or '').lower()
# Skip obvious non-permanents (rituals etc.)
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
continue
# Keep only candidates that are lands OR whose text indicates mana production
produces_from_text = False
tf = text_field
if tf:
# Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour"
produces_from_text = (
('add one mana of any color' in tf) or
('add one mana of any colour' in tf) or
('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf))
)
if not (is_land or produces_from_text):
continue
# Combine entry type and snapshot type line for robust parsing
tline = (entry_type + ' ' + tline_full).strip()
colors = {c: 0 for c in (COLOR_LETTERS + ['C'])}
# Land type-based inference
if is_land:
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
# Text-based inference for both lands and non-lands
if (
'add one mana of any color' in tf or
'add one mana of any colour' in tf or
('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf))
):
for k in COLOR_LETTERS:
colors[k] = 1
for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]:
if sym in text_field:
colors[c] = 1
matrix[name] = colors
# Explicit colored/colorless symbols in add context
if 'add' in tf:
if '{w}' in tf:
colors['W'] = 1
if '{u}' in tf:
colors['U'] = 1
if '{b}' in tf:
colors['B'] = 1
if '{r}' in tf:
colors['R'] = 1
if '{g}' in tf:
colors['G'] = 1
if '{c}' in tf or 'colorless' in tf:
colors['C'] = 1
# Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes
if not any(colors.values()) and is_land:
nm = str(name)
base = nm
if nm.startswith('Snow-Covered '):
base = nm[len('Snow-Covered '):]
mapping = {
'Plains': 'W',
'Island': 'U',
'Swamp': 'B',
'Mountain': 'R',
'Forest': 'G',
'Wastes': 'C',
}
col = mapping.get(base)
if col:
colors[col] = 1
# Only include cards that produced at least one color
if any(colors.values()):
matrix[name] = colors
return matrix
@ -158,6 +215,7 @@ __all__ = [
'compute_spell_pip_weights',
'parse_theme_tags',
'normalize_theme_list',
'prefer_owned_first',
'compute_adjusted_target',
'normalize_tag_cell',
'sort_by_priority',
@ -418,6 +476,32 @@ def sort_by_priority(df, columns: list[str]):
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
- Adds a temporary column to flag ownership, sorts by it desc with mergesort, then drops it.
- If the name column is missing or owned_names_lower empty, returns df unchanged.
"""
try:
if df is None or getattr(df, 'empty', True):
return df
if not owned_names_lower:
return df
if name_col not in df.columns:
return df
tmp_col = '_ownedPref'
# Avoid clobbering if already present
while tmp_col in df.columns:
tmp_col = tmp_col + '_x'
ser = df[name_col].astype(str).str.lower().isin(owned_names_lower).astype(int)
df = df.assign(**{tmp_col: ser})
df = df.sort_values(by=[tmp_col], ascending=[False], kind='mergesort')
df = df.drop(columns=[tmp_col])
return df
except Exception:
return df
# ---------------------------------------------------------------------------
# Tag-driven land suggestion helpers
# ---------------------------------------------------------------------------

View file

@ -24,11 +24,56 @@ class CommanderSelectionMixin:
# ---------------------------
# Commander Selection
# ---------------------------
def _normalize_commander_query(self, s: str) -> str:
"""Return a nicely capitalized search string (e.g., "inti, seneschal of the sun"
-> "Inti, Seneschal of the Sun"). Keeps small words lowercase unless at a segment start,
and capitalizes parts around hyphens/apostrophes.
"""
if not isinstance(s, str):
return str(s)
s = s.strip()
if not s:
return s
small = {
'a','an','and','as','at','but','by','for','in','of','on','or','the','to','vs','v','with','from','into','over','per'
}
# Consider a new segment after these punctuation marks
segment_breakers = {':',';','-','','','/','\\','(', '[', '{', '"', "'", ',', '.'}
out_words: list[str] = []
start_of_segment = True
for raw in s.lower().split():
word = raw
# If preceding token ended with a breaker, reset segment
if out_words:
prev = out_words[-1]
if prev and prev[-1] in segment_breakers:
start_of_segment = True
def cap_subparts(token: str) -> str:
# Capitalize around hyphens and apostrophes
def cap_piece(piece: str) -> str:
return piece[:1].upper() + piece[1:] if piece else piece
parts = [cap_piece(p) for p in token.split("'")]
token2 = "'".join(parts)
parts2 = [cap_piece(p) for p in token2.split('-')]
return '-'.join(parts2)
if start_of_segment or word not in small:
fixed = cap_subparts(word)
else:
fixed = word
out_words.append(fixed)
# Next word is not start unless current ends with breaker
start_of_segment = word[-1:] in segment_breakers
# Post-process to ensure first character is capitalized if needed
if out_words:
out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
return ' '.join(out_words)
def choose_commander(self) -> str: # type: ignore[override]
df = self.load_commander_data()
names = df["name"].tolist()
while True:
query = self.input_func("Enter commander name: ").strip()
query = self._normalize_commander_query(query)
if not query:
self.output_func("No input provided. Try again.")
continue
@ -66,7 +111,7 @@ class CommanderSelectionMixin:
else:
self.output_func("Invalid index.")
continue
query = choice # treat as new query
query = self._normalize_commander_query(choice) # treat as new (normalized) query
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
row = df[df["name"] == name].iloc[0]

View file

@ -144,8 +144,17 @@ class LandFetchMixin:
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
"""Public wrapper to add fetch lands. Optional requested_count to bypass prompt."""
self.add_fetch_lands(requested_count=requested_count)
"""Public wrapper to add fetch lands.
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
"""
desired = requested_count
try:
if desired is None and getattr(self, 'ideal_counts', None) and 'fetch_lands' in self.ideal_counts:
desired = int(self.ideal_counts['fetch_lands'])
except Exception:
desired = requested_count
self.add_fetch_lands(requested_count=desired)
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
__all__ = [

View file

@ -85,11 +85,74 @@ class CreatureAdditionMixin:
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
creature_df['_normTags'] = creature_df['_parsedThemeTags']
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
combine_mode = getattr(self, 'tag_mode', 'AND')
base_top = 30
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
total_added = 0
added_names: List[str] = []
# AND pre-pass: pick creatures that hit all selected themes first (if 2+ themes)
all_theme_added: List[tuple[str, List[str]]] = []
if combine_mode == 'AND' and len(selected_tags_lower) >= 2:
all_cnt = len(selected_tags_lower)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
remaining_capacity = max(0, desired_total - total_added)
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
if target_cap > 0:
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
subset_all = subset_all[~subset_all['name'].isin(added_names)]
if not subset_all.empty:
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
# Bias owned names ahead before weighting
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
for nm in chosen_all:
if commander_name and nm == commander_name:
continue
row = subset_all[subset_all['name'] == nm].iloc[0]
# Which selected themes does this card hit?
selected_display_tags = [t for _r, t in themes_ordered]
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
try:
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
except Exception:
hits = selected_display_tags
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
)
added_names.append(nm)
all_theme_added.append((nm, hits))
total_added += 1
if total_added >= desired_total:
break
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
# Per-theme distribution
per_theme_added: Dict[str, List[str]] = {r: [] for r,_t in themes_ordered}
for role, tag in themes_ordered:
w = weights.get(role, 0.0)
@ -104,6 +167,9 @@ class CreatureAdditionMixin:
continue
tnorm = tag.lower()
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
if (creature_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty:
self.output_func(f"Theme '{tag}' produced no creature candidates.")
continue
@ -111,11 +177,30 @@ class CreatureAdditionMixin:
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(added_names)]
if pool.empty:
continue
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
if combine_mode == 'AND':
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
else:
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus if mm >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen:
if commander_name and nm == commander_name:
@ -142,15 +227,26 @@ class CreatureAdditionMixin:
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
if total_added >= desired_total:
break
# Fill remaining if still short
if total_added < desired_total:
need = desired_total - total_added
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty:
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
multi_pool = prioritized
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
if commander_name and nm == commander_name:
@ -173,7 +269,15 @@ class CreatureAdditionMixin:
if total_added >= desired_total:
break
self.output_func(f"Fill pass added {min(need, len(fill))} extra creatures (shortfall compensation).")
# Summary output
self.output_func("\nCreatures Added:")
if all_theme_added:
self.output_func(f" All-Theme overlap: {len(all_theme_added)}")
for nm, hits in all_theme_added:
if hits:
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
else:
self.output_func(f" - {nm}")
for role, tag in themes_ordered:
lst = per_theme_added.get(role, [])
if lst:
@ -190,3 +294,267 @@ class CreatureAdditionMixin:
"""
"""Public method for orchestration: delegates to add_creatures."""
return self.add_creatures()
# ---------------------------
# Per-theme creature sub-stages (for web UI staged confirms)
# ---------------------------
def _theme_weights(self, themes_ordered: List[tuple[str, str]]) -> Dict[str, float]:
n_themes = len(themes_ordered)
if n_themes == 1:
base_map = {'primary': 1.0}
elif n_themes == 2:
base_map = {'primary': 0.6, 'secondary': 0.4}
else:
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
weights: Dict[str, float] = {}
boosted_roles: set[str] = set()
if n_themes > 1:
for role, tag in themes_ordered:
w = base_map.get(role, 0.0)
lt = tag.lower()
if 'kindred' in lt or 'tribal' in lt:
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
w *= mult
boosted_roles.add(role)
weights[role] = w
total = sum(weights.values())
if total > 1.0:
for r in list(weights):
weights[r] /= total
else:
rem = 1.0 - total
base_sum_unboosted = sum(base_map[r] for r,_t in themes_ordered if r not in boosted_roles)
if rem > 1e-6 and base_sum_unboosted > 0:
for r,_t in themes_ordered:
if r not in boosted_roles:
weights[r] += rem * (base_map[r] / base_sum_unboosted)
else:
weights['primary'] = 1.0
return weights
def _creature_count_in_library(self) -> int:
total = 0
try:
for _n, entry in getattr(self, 'card_library', {}).items():
if str(entry.get('Role') or '').strip() == 'creature':
total += int(entry.get('Count', 1))
except Exception:
pass
return total
def _prepare_creature_pool(self):
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty or 'type' not in df.columns:
return None
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
if commander_name and 'name' in creature_df.columns:
creature_df = creature_df[creature_df['name'] != commander_name]
if creature_df.empty:
return None
if '_parsedThemeTags' not in creature_df.columns:
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
creature_df['_normTags'] = creature_df['_parsedThemeTags']
selected_tags_lower: List[str] = []
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if t:
selected_tags_lower.append(t.lower())
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
return creature_df
def _add_creatures_for_role(self, role: str):
"""Add creatures for a single theme role ('primary'|'secondary'|'tertiary')."""
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty:
self.output_func("Card pool not loaded; cannot add creatures.")
return
tag = getattr(self, f'{role}_tag', None)
if not tag:
return
themes_ordered: List[tuple[str, str]] = []
if getattr(self, 'primary_tag', None):
themes_ordered.append(('primary', self.primary_tag))
if getattr(self, 'secondary_tag', None):
themes_ordered.append(('secondary', self.secondary_tag))
if getattr(self, 'tertiary_tag', None):
themes_ordered.append(('tertiary', self.tertiary_tag))
weights = self._theme_weights(themes_ordered)
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
remaining = max(0, desired_total - current_added)
if remaining <= 0:
return
w = float(weights.get(role, 0.0))
if w <= 0:
return
import math as _math
target = int(_math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
target = min(target, remaining)
if target <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None:
self.output_func("No creature rows in dataset; skipping.")
return
tnorm = str(tag).lower()
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
if subset.empty:
self.output_func(f"Theme '{tag}' produced no creature candidates.")
return
if 'edhrecRank' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
base_top = 30
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
pool = subset.head(top_n).copy()
# Exclude any names already chosen
existing_names = set(getattr(self, 'card_library', {}).keys())
pool = pool[~pool['name'].isin(existing_names)]
if pool.empty:
return
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
added = 0
for nm in chosen:
row = pool[pool['name']==nm].iloc[0]
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role=role,
added_by='creature_add',
trigger_tag=tag,
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added += 1
if added >= target:
break
self.output_func(f"Added {added} creatures for {role} theme '{tag}' (target {target}).")
def _add_creatures_fill(self):
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
need = max(0, desired_total - current_added)
if need <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None:
return
multi_pool = creature_df[~creature_df['name'].isin(set(getattr(self, 'card_library', {}).keys()))].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if multi_pool.empty:
return
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
fill = multi_pool['name'].tolist()[:need]
added = 0
for nm in fill:
row = multi_pool[multi_pool['name']==nm].iloc[0]
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role='fill',
added_by='creature_fill',
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added += 1
if added >= need:
break
if added:
self.output_func(f"Fill pass added {added} extra creatures (shortfall compensation).")
# Public stage entry points (web orchestrator looks for these)
def add_creatures_primary_phase(self):
return self._add_creatures_for_role('primary')
def add_creatures_secondary_phase(self):
return self._add_creatures_for_role('secondary')
def add_creatures_tertiary_phase(self):
return self._add_creatures_for_role('tertiary')
def add_creatures_fill_phase(self):
return self._add_creatures_fill()
def add_creatures_all_theme_phase(self):
"""Staged pre-pass: when AND mode and 2+ tags, add creatures matching all selected themes first."""
combine_mode = getattr(self, 'tag_mode', 'AND')
tags = [t for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)] if t]
if combine_mode != 'AND' or len(tags) < 2:
return
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
remaining_capacity = max(0, desired_total - current_added)
if remaining_capacity <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None or creature_df.empty:
return
all_cnt = len(tags)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
existing_names = set(getattr(self, 'card_library', {}).keys())
subset_all = subset_all[~subset_all['name'].isin(existing_names)]
if subset_all.empty or target_cap <= 0:
return
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
added = 0
for nm in chosen_all:
row = subset_all[subset_all['name'] == nm].iloc[0]
# Determine which selected themes this card hits for display
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
hits: List[str] = []
try:
hits = [t for t in tags if str(t).lower() in norm_tags]
except Exception:
hits = list(tags)
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
)
added += 1
if added >= target_cap:
break
if added:
self.output_func(f"All-Theme AND Pre-Pass: added {added}/{target_cap} creatures (matching all {all_cnt} themes)")

View file

@ -57,6 +57,12 @@ class SpellAdditionMixin:
if commander_name:
work = work[work['name'] != commander_name]
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
# Prefer-owned bias: stable reorder to put owned first while preserving prior sort
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
owned_lower = {str(n).lower() for n in owned_set}
work = bu.prefer_owned_first(work, owned_lower)
rocks_target = min(target_total, math.ceil(target_total/3))
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
@ -143,6 +149,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
lt = [str(t).lower() for t in entry.get('Tags', [])]
@ -201,6 +211,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -277,6 +291,12 @@ class SpellAdditionMixin:
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
conditional_df = sortit(conditional_df)
unconditional_df = sortit(unconditional_df)
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
owned_lower = {str(n).lower() for n in owned_set}
conditional_df = bu.prefer_owned_first(conditional_df, owned_lower)
unconditional_df = bu.prefer_owned_first(unconditional_df, owned_lower)
added_cond = 0
added_cond_names: List[str] = []
for _, r in conditional_df.iterrows():
@ -349,6 +369,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -452,6 +476,7 @@ class SpellAdditionMixin:
spells_df['_multiMatch'] = spells_df['_normTags'].apply(
lambda lst: sum(1 for t in selected_tags_lower if t in lst)
)
combine_mode = getattr(self, 'tag_mode', 'AND')
base_top = 40
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
@ -473,6 +498,9 @@ class SpellAdditionMixin:
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
)
]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
if (spells_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty:
continue
if 'edhrecRank' in subset.columns:
@ -487,11 +515,32 @@ class SpellAdditionMixin:
ascending=[False, True],
na_position='last',
)
# Prefer-owned: stable reorder before trimming to top_n
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty:
continue
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
# Build weighted pool with optional owned multiplier
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
weighted_pool: list[tuple[str, float]] = []
if combine_mode == 'AND':
for nm, mm in base_pairs:
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
else:
for nm, mm in base_pairs:
base_w = (synergy_bonus if mm >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen:
row = pool[pool['name'] == nm].iloc[0]
@ -514,7 +563,13 @@ class SpellAdditionMixin:
if total_added < remaining:
need = remaining - total_added
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty:
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
multi_pool = prioritized
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(
@ -528,6 +583,10 @@ class SpellAdditionMixin:
ascending=[False, True],
na_position='last',
)
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
row = multi_pool[multi_pool['name'] == nm].iloc[0]
@ -585,6 +644,10 @@ class SpellAdditionMixin:
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
row = subset.head(1)
if row.empty:
break

View file

@ -61,7 +61,7 @@ class ColorBalanceMixin:
self,
pip_weights: Optional[Dict[str, float]] = None,
color_shortfall_threshold: float = 0.15,
perform_swaps: bool = True,
perform_swaps: bool = False,
max_swaps: int = 5,
rebalance_basics: bool = True
):
@ -93,54 +93,56 @@ class ColorBalanceMixin:
self.output_func(" Deficits (need more sources):")
for c, pip_share, s_share, gap in deficits:
self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)")
if not perform_swaps or not deficits:
# We'll conditionally perform swaps; but even when skipping swaps we continue to basic rebalance.
do_swaps = bool(perform_swaps and deficits)
if not do_swaps:
self.output_func(" (No land swaps performed.)")
return
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty:
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
return
deficits.sort(key=lambda x: x[3], reverse=True)
swaps_done: List[tuple[str,str,str]] = []
overages: Dict[str,float] = {}
for c in ['W','U','B','R','G']:
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
if over > 0:
overages[c] = over
if do_swaps:
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty:
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
else:
deficits.sort(key=lambda x: x[3], reverse=True)
overages: Dict[str,float] = {}
for c in ['W','U','B','R','G']:
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
if over > 0:
overages[c] = over
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
return bu.select_color_balance_removal(self, exclude_colors, overages)
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
return bu.select_color_balance_removal(self, exclude_colors, overages)
def addition_candidates(target_color: str) -> List[str]:
return bu.color_balance_addition_candidates(self, target_color, df)
def addition_candidates(target_color: str) -> List[str]:
return bu.color_balance_addition_candidates(self, target_color, df)
for color, _, _, gap in deficits:
if len(swaps_done) >= max_swaps:
break
adds = addition_candidates(color)
if not adds:
continue
to_add = None
for cand in adds:
if cand not in self.card_library:
to_add = cand
break
if not to_add:
continue
to_remove = removal_candidate({color})
if not to_remove:
continue
if not self._decrement_card(to_remove):
continue
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
swaps_done.append((to_remove, to_add, color))
current_counts = self._current_color_source_counts()
total_sources = sum(current_counts.values()) or 1
source_share = {c: current_counts[c]/total_sources for c in current_counts}
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
if new_gap <= color_shortfall_threshold:
continue
for color, _, _, gap in deficits:
if len(swaps_done) >= max_swaps:
break
adds = addition_candidates(color)
if not adds:
continue
to_add = None
for cand in adds:
if cand not in self.card_library:
to_add = cand
break
if not to_add:
continue
to_remove = removal_candidate({color})
if not to_remove:
continue
if not self._decrement_card(to_remove):
continue
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
swaps_done.append((to_remove, to_add, color))
current_counts = self._current_color_source_counts()
total_sources = sum(current_counts.values()) or 1
source_share = {c: current_counts[c]/total_sources for c in current_counts}
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
if new_gap <= color_shortfall_threshold:
continue
if swaps_done:
self.output_func("\nColor Balance Swaps Performed:")
@ -152,52 +154,54 @@ class ColorBalanceMixin:
self.output_func(" Updated Source Shares:")
for c in ['W','U','B','R','G']:
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
if rebalance_basics:
try:
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
if basics_present:
total_basics = sum(e.get('Count',1) for e in basics_present.values())
if total_basics > 0:
desired_per_color: Dict[str,int] = {}
for c, basic_name in basic_map.items():
if c not in ['W','U','B','R','G']:
continue
desired = pip_weights.get(c,0.0) * total_basics
desired_per_color[c] = int(round(desired))
drift = total_basics - sum(desired_per_color.values())
if drift != 0:
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
i = 0
while drift != 0 and ordered:
c,_ = ordered[i % len(ordered)]
desired_per_color[c] += 1 if drift>0 else -1
drift += -1 if drift>0 else 1
i += 1
changes: List[tuple[str,int,int]] = []
for c, basic_name in basic_map.items():
if c not in ['W','U','B','R','G']:
continue
target = max(0, desired_per_color.get(c,0))
entry = self.card_library.get(basic_name)
old = entry.get('Count',0) if entry else 0
if old == 0 and target>0:
for _ in range(target):
self.add_card(basic_name, card_type='Land')
changes.append((basic_name, 0, target))
elif entry and old != target:
if target > old:
for _ in range(target-old):
self.add_card(basic_name, card_type='Land')
else:
for _ in range(old-target):
self._decrement_card(basic_name)
changes.append((basic_name, old, target))
if changes:
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
for nm, old, new in changes:
self.output_func(f" {nm}: {old} -> {new}")
except Exception as e: # pragma: no cover (defensive)
self.output_func(f" Basic rebalance skipped (error: {e})")
else:
elif do_swaps:
self.output_func(" (No viable swaps executed.)")
# Always consider basic-land rebalance when requested
if rebalance_basics:
try:
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
if basics_present:
total_basics = sum(e.get('Count',1) for e in basics_present.values())
if total_basics > 0:
desired_per_color: Dict[str,int] = {}
for c, basic_name in basic_map.items():
if c not in ['W','U','B','R','G']:
continue
desired = pip_weights.get(c,0.0) * total_basics
desired_per_color[c] = int(round(desired))
drift = total_basics - sum(desired_per_color.values())
if drift != 0:
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
i = 0
while drift != 0 and ordered:
c,_ = ordered[i % len(ordered)]
desired_per_color[c] += 1 if drift>0 else -1
drift += -1 if drift>0 else 1
i += 1
changes: List[tuple[str,int,int]] = []
for c, basic_name in basic_map.items():
if c not in ['W','U','B','R','G']:
continue
target = max(0, desired_per_color.get(c,0))
entry = self.card_library.get(basic_name)
old = entry.get('Count',0) if entry else 0
if old == 0 and target>0:
for _ in range(target):
self.add_card(basic_name, card_type='Land')
changes.append((basic_name, 0, target))
elif entry and old != target:
if target > old:
for _ in range(target-old):
self.add_card(basic_name, card_type='Land')
else:
for _ in range(old-target):
self._decrement_card(basic_name)
changes.append((basic_name, old, target))
if changes:
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
for nm, old, new in changes:
self.output_func(f" {nm}: {old} -> {new}")
except Exception as e: # pragma: no cover (defensive)
self.output_func(f" Basic rebalance skipped (error: {e})")

View file

@ -108,6 +108,213 @@ class ReportingMixin:
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
pct = (c / total_cards * 100) if total_cards else 0.0
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
# ---------------------------
# Structured deck summary for UI (types, pips, sources, curve)
# ---------------------------
def build_deck_summary(self) -> dict:
"""Return a structured summary of the finished deck for UI rendering.
Structure:
{
'type_breakdown': {
'counts': { type: count, ... },
'order': [sorted types by precedence],
'cards': { type: [ {name, count}, ... ] },
'total': int
},
'pip_distribution': {
'counts': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n },
'weights': { 'W': 0-1, ... }, # normalized weights (may not sum exactly to 1 due to rounding)
},
'mana_generation': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n, 'total_sources': n },
'mana_curve': { '0': n, '1': n, '2': n, '3': n, '4': n, '5': n, '6+': n, 'total_spells': n }
}
"""
# Build lookup to enrich type and mana values
full_df = getattr(self, '_full_cards_df', None)
combined_df = getattr(self, '_combined_cards_df', None)
snapshot = full_df if full_df is not None else combined_df
row_lookup: Dict[str, any] = {}
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
for _, r in snapshot.iterrows(): # type: ignore[attr-defined]
nm = str(r.get('name'))
if nm and nm not in row_lookup:
row_lookup[nm] = r
# Category classification (reuse export logic)
precedence_order = [
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
]
precedence_index = {k: i for i, k in enumerate(precedence_order)}
commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
def classify(primary_type_line: str, card_name: str) -> str:
if commander_name and card_name == commander_name:
return 'Commander'
tl = (primary_type_line or '').lower()
if 'battle' in tl:
return 'Battle'
if 'planeswalker' in tl:
return 'Planeswalker'
if 'creature' in tl:
return 'Creature'
if 'instant' in tl:
return 'Instant'
if 'sorcery' in tl:
return 'Sorcery'
if 'artifact' in tl:
return 'Artifact'
if 'enchantment' in tl:
return 'Enchantment'
if 'land' in tl:
return 'Land'
return 'Other'
# Type breakdown (counts and per-type card lists)
type_counts: Dict[str, int] = {}
type_cards: Dict[str, list] = {}
total_cards = 0
for name, info in self.card_library.items():
# Exclude commander from type breakdown per UI preference
if commander_name and name == commander_name:
continue
cnt = int(info.get('Count', 1))
base_type = info.get('Card Type') or info.get('Type', '')
if not base_type:
row = row_lookup.get(name)
if row is not None:
base_type = row.get('type', row.get('type_line', '')) or ''
category = classify(base_type, name)
type_counts[category] = type_counts.get(category, 0) + cnt
total_cards += cnt
type_cards.setdefault(category, []).append({
'name': name,
'count': cnt,
'role': info.get('Role', '') or '',
'tags': list(info.get('Tags', []) or []),
})
# Sort cards within each type by name
for cat, lst in type_cards.items():
lst.sort(key=lambda x: (x['name'].lower(), -int(x['count'])))
type_order = sorted(type_counts.keys(), key=lambda k: precedence_index.get(k, 999))
# Pip distribution (counts and weights) for non-land spells only
pip_counts = {c: 0 for c in ('W','U','B','R','G')}
# For UI cross-highlighting: map color -> list of cards that have that color pip in their cost
pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')}
import re as _re_local
total_pips = 0.0
for name, info in self.card_library.items():
ctype = str(info.get('Card Type', ''))
if 'land' in ctype.lower():
continue
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
if not isinstance(mana_cost, str):
continue
# Track which colors appear for this card's mana cost for card listing
colors_for_card = set()
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
sym = match.upper()
if len(sym) == 1 and sym in pip_counts:
pip_counts[sym] += 1
total_pips += 1
colors_for_card.add(sym)
elif '/' in sym:
parts = [p for p in sym.split('/') if p in pip_counts]
if parts:
weight_each = 1 / len(parts)
for p in parts:
pip_counts[p] += weight_each
total_pips += weight_each
colors_for_card.add(p)
elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color
base = sym[0]
if base in pip_counts:
pip_counts[base] += 1
total_pips += 1
colors_for_card.add(base)
if colors_for_card:
cnt = int(info.get('Count', 1))
for c in colors_for_card:
pip_cards[c].append({'name': name, 'count': cnt})
if total_pips <= 0:
# Fallback to even distribution across color identity
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
if colors:
share = 1 / len(colors)
for c in colors:
pip_counts[c] = share
total_pips = 1.0
pip_weights = {c: (pip_counts[c] / total_pips if total_pips else 0.0) for c in pip_counts}
# Mana generation from lands (color sources)
try:
from deck_builder import builder_utils as _bu
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
except Exception:
matrix = {}
source_counts = {c: 0 for c in ('W','U','B','R','G','C')}
# For UI cross-highlighting: color -> list of cards that produce that color (typically lands, possibly others)
source_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G','C')}
for name, flags in matrix.items():
copies = int(self.card_library.get(name, {}).get('Count', 1))
for c in source_counts.keys():
if int(flags.get(c, 0)):
source_counts[c] += copies
source_cards[c].append({'name': name, 'count': copies})
total_sources = sum(source_counts.values())
# Mana curve (non-land spells)
curve_bins = ['0','1','2','3','4','5','6+']
curve_counts = {b: 0 for b in curve_bins}
curve_cards: Dict[str, list] = {b: [] for b in curve_bins}
total_spells = 0
for name, info in self.card_library.items():
ctype = str(info.get('Card Type', ''))
if 'land' in ctype.lower():
continue
cnt = int(info.get('Count', 1))
mv = info.get('Mana Value')
if mv in (None, ''):
row = row_lookup.get(name)
if row is not None:
mv = row.get('manaValue', row.get('cmc', None))
try:
val = float(mv) if mv not in (None, '') else 0.0
except Exception:
val = 0.0
bucket = '6+' if val >= 6 else str(int(val))
if bucket not in curve_counts:
bucket = '6+'
curve_counts[bucket] += cnt
curve_cards[bucket].append({'name': name, 'count': cnt})
total_spells += cnt
return {
'type_breakdown': {
'counts': type_counts,
'order': type_order,
'cards': type_cards,
'total': total_cards,
},
'pip_distribution': {
'counts': pip_counts,
'weights': pip_weights,
'cards': pip_cards,
},
'mana_generation': {
**source_counts,
'total_sources': total_sources,
'cards': source_cards,
},
'mana_curve': {
**curve_counts,
'total_spells': total_spells,
'cards': curve_cards,
},
'colors': list(getattr(self, 'color_identity', []) or []),
}
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
"""Export current decklist to CSV (enriched).
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
@ -136,22 +343,31 @@ class ReportingMixin:
return candidate
i += 1
if filename is None:
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
themes: List[str] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
# 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')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
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
themes: List[str] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
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)
@ -207,12 +423,21 @@ class ReportingMixin:
except Exception:
owned_set_lower = set()
# Fallback oracle text for basic lands to ensure CSV has meaningful text
BASIC_TEXT = {
'Plains': '({T}: Add {W}.)',
'Island': '({T}: Add {U}.)',
'Swamp': '({T}: Add {B}.)',
'Mountain': '({T}: Add {R}.)',
'Forest': '({T}: Add {G}.)',
'Wastes': '({T}: Add {C}.)',
}
for name, info in self.card_library.items():
base_type = info.get('Card Type') or info.get('Type','')
base_mc = info.get('Mana Cost','')
base_mv = info.get('Mana Value', info.get('CMC',''))
role = info.get('Role','') or ''
tags = info.get('Tags',[]) or []
base_type = info.get('Card Type') or info.get('Type', '')
base_mc = info.get('Mana Cost', '')
base_mv = info.get('Mana Value', info.get('CMC', ''))
role = info.get('Role', '') or ''
tags = info.get('Tags', []) or []
tags_join = '; '.join(tags)
text_field = ''
colors = ''
@ -237,6 +462,9 @@ class ReportingMixin:
power = row.get('power', '') or ''
toughness = row.get('toughness', '') or ''
text_field = row.get('text', row.get('oracleText', '')) or ''
# If still no text and this is a basic, inject fallback oracle snippet
if (not text_field) and (str(name) in BASIC_TEXT):
text_field = BASIC_TEXT[str(name)]
# Normalize and coerce text
if isinstance(text_field, str):
cleaned = text_field
@ -260,7 +488,7 @@ class ReportingMixin:
owned_flag = 'Y' if (name.lower() in owned_set_lower) else ''
rows.append(((prec, name.lower()), [
name,
info.get('Count',1),
info.get('Count', 1),
base_type,
base_mc,
base_mv,
@ -276,6 +504,7 @@ class ReportingMixin:
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
owned_flag
]))
# Now sort (category precedence, then alphabetical name)
rows.sort(key=lambda x: x[0])
@ -314,21 +543,30 @@ class ReportingMixin:
return candidate
i += 1
if filename is None:
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] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
# 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')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
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] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
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))
@ -423,21 +661,30 @@ class ReportingMixin:
i += 1
if filename is None:
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] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
# 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')
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
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] = []
if getattr(self, 'selected_tags', None):
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
else:
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
if isinstance(t, str) and t.strip():
themes.append(t)
theme_parts = [_slug(t) for t in themes if t]
if not theme_parts:
theme_parts = ['notheme']
theme_slug = '_'.join(theme_parts)
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
filename = f"{stem}.json"
path = _unique_path(os.path.join(directory, filename))
@ -452,6 +699,7 @@ class ReportingMixin:
"secondary_tag": getattr(self, 'secondary_tag', None),
"tertiary_tag": getattr(self, 'tertiary_tag', None),
"bracket_level": getattr(self, 'bracket_level', None),
"tag_mode": (getattr(self, 'tag_mode', 'AND') or 'AND'),
"use_multi_theme": True,
"add_lands": True,
"add_creatures": True,

View file

@ -198,24 +198,24 @@ def regenerate_csv_by_color(color: str) -> None:
try:
if color not in SETUP_COLORS:
raise ValueError(f'Invalid color: {color}')
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
logger.info(f'Downloading latest card data for {color} cards')
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
logger.info('Loading and processing card data')
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
)
logger.info(f'Successfully regenerated {color} cards database')
except Exception as e:
logger.error(f'Failed to regenerate {color} cards: {str(e)}')
raise

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

@ -6,6 +6,7 @@ import os
from typing import Any, Dict, List, Optional
from deck_builder.builder import DeckBuilder
from deck_builder import builder_constants as bc
def run(
command_name: str = "",
@ -47,14 +48,27 @@ def run(
scripted_inputs.append("0") # stop at primary
# Bracket (meta power / style) selection; default to 3 if not provided
scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3))
# Ideal count prompts (press Enter for defaults)
for _ in range(8):
scripted_inputs.append("")
# Ideal count prompts (press Enter for defaults). Include fetch_lands if present.
ideal_keys = {
"ramp",
"lands",
"basic_lands",
"fetch_lands",
"creatures",
"removal",
"wipes",
"card_advantage",
"protection",
}
for key in bc.DECK_COMPOSITION_PROMPTS.keys():
if key in ideal_keys:
scripted_inputs.append("")
def scripted_input(prompt: str) -> str:
if scripted_inputs:
return scripted_inputs.pop(0)
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
# Fallback to auto-accept defaults for any unexpected prompts
return ""
builder = DeckBuilder(input_func=scripted_input)
# Mark this run as headless so builder can adjust exports and logging

View file

@ -4501,7 +4501,7 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None:
rules = []
air_mask = tag_utils.create_text_mask(df, 'airbend')
if air_mask.any():
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] })
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters', 'Leave the Battlefield'] })
water_mask = tag_utils.create_text_mask(df, 'waterbend')
if water_mask.any():

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,91 @@
import os
import importlib
import types
import pytest
from starlette.testclient import TestClient
fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed
def load_app_with_env(**env: str) -> types.ModuleType:
for k, v in env.items():
os.environ[k] = v
import code.web.app as app_module # type: ignore
importlib.reload(app_module)
return app_module
def test_healthz_ok_and_request_id_header():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/healthz")
assert r.status_code == 200
data = r.json()
assert data.get("status") in {"ok", "degraded"}
assert "uptime_seconds" in data
assert r.headers.get("X-Request-ID")
def test_404_renders_html_when_accept_html():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/this-does-not-exist", headers={"Accept": "text/html"})
assert r.status_code == 404
body = r.text.lower()
assert "page not found" in body
assert "go home" in body
assert r.headers.get("X-Request-ID")
def test_htmx_http_exception_returns_json_with_request_id():
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
client = TestClient(app_module.app)
r = client.get("/diagnostics/trigger-error", headers={"HX-Request": "true"})
assert r.status_code == 418
data = r.json()
assert data.get("error") is True
assert data.get("status") == 418
assert data.get("request_id")
assert r.headers.get("X-Request-ID")
def test_unhandled_exception_returns_500_json_with_request_id():
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
# Configure client to not re-raise server exceptions so we can assert the 500 response
client = TestClient(app_module.app, raise_server_exceptions=False)
r = client.get("/diagnostics/trigger-error?kind=unhandled", headers={"HX-Request": "true"})
assert r.status_code == 500
data = r.json()
assert data.get("error") is True
assert data.get("status") == 500
assert data.get("request_id")
assert r.headers.get("X-Request-ID")
def test_status_sys_summary_and_flags():
app_module = load_app_with_env(
SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1",
ENABLE_THEMES="1",
ENABLE_PWA="1",
ENABLE_PRESETS="1",
APP_VERSION="testver",
THEME="dark",
)
client = TestClient(app_module.app)
r = client.get("/status/sys")
assert r.status_code == 200
data = r.json()
assert data.get("version") == "testver"
assert isinstance(data.get("uptime_seconds"), int)
assert isinstance(data.get("server_time_utc"), str)
flags = data.get("flags") or {}
assert flags.get("SHOW_LOGS") is True
assert flags.get("SHOW_DIAGNOSTICS") is True
assert flags.get("SHOW_SETUP") is True
# Theme-related flags
assert flags.get("ENABLE_THEMES") is True
assert flags.get("ENABLE_PWA") is True
assert flags.get("ENABLE_PRESETS") is True
assert flags.get("DEFAULT_THEME") == "dark"

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

1
code/web/__init__.py Normal file
View file

@ -0,0 +1 @@
# Web package marker

398
code/web/app.py Normal file
View file

@ -0,0 +1,398 @@
from __future__ import annotations
from fastapi import FastAPI, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pathlib import Path
import os
import json as _json
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
_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():
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))
# Global template flags (env-driven)
def _as_bool(val: str | None, default: bool = False) -> bool:
if val is None:
return default
return val.strip().lower() in {"1", "true", "yes", "on"}
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)
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
DEFAULT_THEME = "system"
if _THEME_ENV in {"light", "dark", "system"}:
DEFAULT_THEME = _THEME_ENV
# 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,
"enable_themes": ENABLE_THEMES,
"enable_pwa": ENABLE_PWA,
"enable_presets": ENABLE_PRESETS,
"default_theme": DEFAULT_THEME,
})
# --- 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()
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
"""Assign or propagate a request id and attach to response headers."""
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
request.state.request_id = rid
try:
response = await call_next(request)
except Exception as ex:
# Log and re-raise so FastAPI exception handlers can format the response.
logging.getLogger("web").error(f"Unhandled error [rid={rid}]: {ex}", exc_info=True)
raise
response.headers["X-Request-ID"] = rid
return response
@app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
# Simple health check (hardened)
@app.get("/healthz")
async def healthz():
try:
version = os.getenv("APP_VERSION", "dev")
uptime_s = int(time.time() - _APP_START_TIME)
return {"status": "ok", "version": version, "uptime_seconds": uptime_s}
except Exception:
# Avoid throwing from health
return {"status": "degraded"}
# System summary endpoint for diagnostics
@app.get("/status/sys")
async def status_sys():
try:
version = os.getenv("APP_VERSION", "dev")
uptime_s = int(time.time() - _APP_START_TIME)
server_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
return {
"version": version,
"uptime_seconds": uptime_s,
"server_time_utc": server_time,
"flags": {
"SHOW_LOGS": bool(SHOW_LOGS),
"SHOW_SETUP": bool(SHOW_SETUP),
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
"ENABLE_THEMES": bool(ENABLE_THEMES),
"ENABLE_PWA": bool(ENABLE_PWA),
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
"DEFAULT_THEME": DEFAULT_THEME,
},
}
except Exception:
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
# Logs tail endpoint (read-only)
@app.get("/status/logs")
async def status_logs(
tail: int = Query(200, ge=1, le=500),
q: str | None = None,
level: str | None = Query(None, description="Optional level filter: error|warning|info|debug"),
):
try:
if not SHOW_LOGS:
# Hide when logs are disabled
return JSONResponse({"error": True, "status": 403, "detail": "Logs disabled"}, status_code=403)
log_path = Path('logs/deck_builder.log')
if not log_path.exists():
return JSONResponse({"lines": [], "count": 0})
from collections import deque
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
lines = list(deque(lf, maxlen=tail))
if q:
ql = q.lower()
lines = [ln for ln in lines if ql in ln.lower()]
# Optional level filter (simple substring match)
if level:
lv = level.strip().lower()
# accept warn as alias for warning
if lv == "warn":
lv = "warning"
if lv in {"error", "warning", "info", "debug"}:
lines = [ln for ln in lines if lv in ln.lower()]
return JSONResponse({"lines": lines, "count": len(lines)})
except Exception:
return JSONResponse({"lines": [], "count": 0})
# Lightweight setup/tagging status endpoint
@app.get("/status/setup")
async def setup_status():
try:
p = Path("csv_files/.setup_status.json")
if p.exists():
with p.open("r", encoding="utf-8") as f:
data = _json.load(f)
# Attach a small log tail if available
try:
log_path = Path('logs/deck_builder.log')
if log_path.exists():
tail_lines = []
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
# Read last ~100 lines efficiently
from collections import deque
tail = deque(lf, maxlen=100)
tail_lines = list(tail)
# Reduce noise: keep lines related to setup/tagging; fallback to last 30 if too few remain
try:
lowered = [ln for ln in tail_lines]
keywords = ["setup", "tag", "color", "csv", "initial setup", "tagging", "load_dataframe"]
filtered = [ln for ln in lowered if any(kw in ln.lower() for kw in keywords)]
if len(filtered) >= 5:
use_lines = filtered[-60:]
else:
use_lines = tail_lines[-30:]
data["log_tail"] = "".join(use_lines).strip()
except Exception:
data["log_tail"] = "".join(tail_lines).strip()
except Exception:
pass
return JSONResponse(data)
return JSONResponse({"running": False, "phase": "idle"})
except Exception:
return JSONResponse({"running": False, "phase": "error"})
# Routers
from .routes import build as build_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402
from .routes import decks as decks_routes # noqa: E402
from .routes import setup as setup_routes # noqa: E402
from .routes import owned as owned_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
# --- Exception handling ---
def _wants_html(request: Request) -> bool:
try:
accept = request.headers.get('accept', '')
is_htmx = request.headers.get('hx-request') == 'true'
return ("text/html" in accept) and not is_htmx
except Exception:
return False
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").warning(
f"HTTPException [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}"
)
if _wants_html(request):
# Friendly HTML page
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
except Exception:
# Fallback plain text
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
# JSON structure for HTMX/API
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
# Also handle Starlette's HTTPException (e.g., 404 route not found)
@app.exception_handler(StarletteHTTPException)
async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").warning(
f"HTTPException* [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}"
)
if _wants_html(request):
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
except Exception:
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").error(
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True
)
if _wants_html(request):
try:
return templates.TemplateResponse("errors/500.html", {"request": request, "request_id": rid}, status_code=500, headers={"X-Request-ID": rid})
except Exception:
return PlainTextResponse(f"Internal Server Error\nRequest-ID: {rid}", status_code=500, headers={"X-Request-ID": rid})
return JSONResponse(status_code=500, content={
"error": True,
"status": 500,
"detail": "Internal Server Error",
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
# Lightweight file download endpoint for exports
@app.get("/files")
async def get_file(path: str):
try:
p = Path(path)
if not p.exists() or not p.is_file():
return PlainTextResponse("File not found", status_code=404)
# Only allow returning files within the workspace directory for safety
# (best-effort: require relative to current working directory)
try:
cwd = Path.cwd().resolve()
if cwd not in p.resolve().parents and p.resolve() != cwd:
# Still allow if under deck_files or config
allowed = any(seg in ("deck_files", "config", "logs") for seg in p.parts)
if not allowed:
return PlainTextResponse("Access denied", status_code=403)
except Exception:
pass
return FileResponse(path)
except Exception:
return PlainTextResponse("Error serving file", status_code=500)
# Serve /favicon.ico from static (prefer .ico, fallback to .png)
@app.get("/favicon.ico")
async def favicon():
try:
ico = _STATIC_DIR / "favicon.ico"
png = _STATIC_DIR / "favicon.png"
target = ico if ico.exists() else (png if png.exists() else None)
if target is None:
return PlainTextResponse("Not found", status_code=404)
return FileResponse(str(target))
except Exception:
return PlainTextResponse("Error", status_code=500)
# Simple Logs page (optional, controlled by SHOW_LOGS)
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(
request: Request,
tail: int = Query(200, ge=1, le=500),
q: str | None = None,
level: str | None = Query(None),
) -> Response:
if not SHOW_LOGS:
# Respect feature flag
raise HTTPException(status_code=404, detail="Not Found")
# Reuse status_logs logic
data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type]
lines: list[str]
if isinstance(data, JSONResponse):
payload = data.body
try:
parsed = _json.loads(payload)
lines = parsed.get("lines", [])
except Exception:
lines = []
else:
lines = []
return templates.TemplateResponse(
"diagnostics/logs.html",
{"request": request, "lines": lines, "tail": tail, "q": q or "", "level": (level or "all")},
)
# Error trigger route for demoing HTMX/global error handling (feature-flagged)
@app.get("/diagnostics/trigger-error")
async def trigger_error(kind: str = Query("http")):
if kind == "http":
raise HTTPException(status_code=418, detail="Teapot: example error for testing")
raise RuntimeError("Example unhandled error for testing")
@app.get("/diagnostics", response_class=HTMLResponse)
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})

View file

@ -0,0 +1 @@
# Routes package marker

1681
code/web/routes/build.py Normal file

File diff suppressed because it is too large Load diff

218
code/web/routes/configs.py Normal file
View file

@ -0,0 +1,218 @@
from __future__ import annotations
from fastapi import APIRouter, Request, Form, UploadFile, File
from fastapi.responses import HTMLResponse
from pathlib import Path
import os
import json
from ..app import templates
from ..services import owned_store
from ..services import orchestrator as orch
from deck_builder import builder_constants as bc
router = APIRouter(prefix="/configs")
def _config_dir() -> Path:
# Prefer explicit env var if provided, else default to ./config
p = os.getenv("DECK_CONFIG")
if p:
# If env points to a file, use its parent dir; else treat as dir
pp = Path(p)
return (pp.parent if pp.suffix else pp).resolve()
return (Path.cwd() / "config").resolve()
def _list_configs() -> list[dict]:
d = _config_dir()
try:
d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
items: list[dict] = []
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
try:
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
meta["commander"] = data.get("commander")
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
meta["tags"] = tags
meta["bracket_level"] = data.get("bracket_level")
except Exception:
pass
items.append(meta)
return items
@router.get("/", response_class=HTMLResponse)
async def configs_index(request: Request) -> HTMLResponse:
items = _list_configs()
# Load example deck.json from the config directory, if present
example_json = None
example_name = "deck.json"
try:
example_path = _config_dir() / example_name
if example_path.exists() and example_path.is_file():
example_json = example_path.read_text(encoding="utf-8")
except Exception:
example_json = None
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
)
@router.get("/view", response_class=HTMLResponse)
async def configs_view(request: Request, name: str) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
# Safety: ensure the resolved path is within config dir
try:
if base not in p.parents and p != base:
raise ValueError("Access denied")
except Exception:
pass
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": "Config not found."},
)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
)
return templates.TemplateResponse(
"configs/view.html",
{"request": request, "path": str(p), "name": p.name, "data": data},
)
@router.post("/run", response_class=HTMLResponse)
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
try:
if base not in p.parents and p != base:
raise ValueError("Access denied")
except Exception:
pass
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": "Config not found."},
)
try:
cfg = json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
)
commander = cfg.get("commander", "")
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
bracket = int(cfg.get("bracket_level") or 0)
ideals = cfg.get("ideal_counts", {}) or {}
# Optional combine mode for tags (AND/OR); support a few aliases
try:
tag_mode = (str(cfg.get("tag_mode") or cfg.get("combine_mode") or cfg.get("mode") or "AND").upper())
if tag_mode not in ("AND", "OR"):
tag_mode = "AND"
except Exception:
tag_mode = "AND"
# Optional owned-only for headless runs via JSON flag or form override
owned_flag = False
try:
uo = cfg.get("use_owned_only")
if isinstance(uo, bool):
owned_flag = uo
elif isinstance(uo, str):
owned_flag = uo.strip().lower() in ("1","true","yes","on")
except Exception:
owned_flag = False
# Form override takes precedence if provided
if use_owned_only is not None:
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
owned_names = owned_store.get_names() if owned_flag else None
# Run build headlessly with orchestrator
res = orch.run_build(
commander=commander,
tags=tags,
bracket=bracket,
ideals=ideals,
tag_mode=tag_mode,
use_owned_only=owned_flag,
owned_names=owned_names,
)
if not res.get("ok"):
return templates.TemplateResponse(
"configs/run_result.html",
{
"request": request,
"ok": False,
"error": res.get("error") or "Build failed",
"log": res.get("log", ""),
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
},
)
return templates.TemplateResponse(
"configs/run_result.html",
{
"request": request,
"ok": True,
"log": res.get("log", ""),
"csv_path": res.get("csv_path"),
"txt_path": res.get("txt_path"),
"summary": res.get("summary"),
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
"game_changers": bc.GAME_CHANGERS,
},
)
@router.post("/upload", response_class=HTMLResponse)
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
# Optional helper: allow uploading a JSON config
try:
content = await file.read()
data = json.loads(content.decode("utf-8"))
# Minimal validation
if not data.get("commander"):
raise ValueError("Missing 'commander'")
except Exception as e:
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
)
# Save to config dir with original filename (or unique)
d = _config_dir()
d.mkdir(parents=True, exist_ok=True)
fname = file.filename or "config.json"
out = d / fname
i = 1
while out.exists():
stem = out.stem
out = d / f"{stem}_{i}.json"
i += 1
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
return templates.TemplateResponse(
"configs/index.html",
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
)

390
code/web/routes/decks.py Normal file
View file

@ -0,0 +1,390 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pathlib import Path
import csv
import os
from typing import Dict, List, Tuple, Optional
from ..app import templates
from ..services import owned_store
from deck_builder import builder_constants as bc
router = APIRouter(prefix="/decks")
def _deck_dir() -> Path:
# Prefer explicit env var if provided, else default to ./deck_files
p = os.getenv("DECK_EXPORTS")
if p:
return Path(p).resolve()
return (Path.cwd() / "deck_files").resolve()
def _list_decks() -> list[dict]:
d = _deck_dir()
try:
d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
items: list[dict] = []
# Prefer CSV entries and pair with matching TXT if present
for p in sorted(d.glob("*.csv"), key=lambda x: x.stat().st_mtime, reverse=True):
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
stem = p.stem
txt = p.with_suffix('.txt')
if txt.exists():
meta["txt_name"] = txt.name
meta["txt_path"] = str(txt)
# Prefer sidecar summary meta if present
sidecar = p.with_suffix('.summary.json')
if sidecar.exists():
try:
import json as _json
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
_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
if not meta.get("commander"):
parts = stem.split('_')
if len(parts) >= 3:
meta["commander"] = parts[0]
meta["tags"] = parts[1:-1]
else:
meta["commander"] = stem
meta["tags"] = []
items.append(meta)
return items
def _safe_within(base: Path, target: Path) -> bool:
try:
base_r = base.resolve()
targ_r = target.resolve()
return (base_r == targ_r) or (base_r in targ_r.parents)
except Exception:
return False
def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, int], Dict[str, List[dict]]]:
"""Parse CSV export to reconstruct minimal summary pieces.
Returns: (meta, type_counts, curve_counts, type_cards)
meta: { 'commander': str, 'colors': [..] }
"""
headers = []
type_counts: Dict[str, int] = {}
type_cards: Dict[str, List[dict]] = {}
curve_bins = ['0','1','2','3','4','5','6+']
curve_counts: Dict[str, int] = {b: 0 for b in curve_bins}
curve_cards: Dict[str, List[dict]] = {b: [] for b in curve_bins}
meta: dict = {"commander": "", "colors": []}
commander_seen = False
# Infer commander from filename stem (pattern Commander_Themes_YYYYMMDD)
stem_parts = csv_path.stem.split('_')
inferred_commander = stem_parts[0] if stem_parts else ''
def classify_mv(raw) -> str:
try:
v = float(raw)
except Exception:
v = 0.0
return '6+' if v >= 6 else str(int(v))
try:
with csv_path.open('r', encoding='utf-8') as f:
reader = csv.reader(f)
headers = next(reader, [])
# Expected columns include: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, ..., Tags, Text, Owned
name_idx = headers.index('Name') if 'Name' in headers else 0
count_idx = headers.index('Count') if 'Count' in headers else 1
type_idx = headers.index('Type') if 'Type' in headers else 2
mv_idx = headers.index('ManaValue') if 'ManaValue' in headers else (headers.index('Mana Value') if 'Mana Value' in headers else -1)
role_idx = headers.index('Role') if 'Role' in headers else -1
tags_idx = headers.index('Tags') if 'Tags' in headers else -1
colors_idx = headers.index('Colors') if 'Colors' 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
type_line = row[type_idx] if type_idx >= 0 and type_idx < len(row) else ''
role = (row[role_idx] if role_idx >= 0 and role_idx < len(row) else '')
tags = (row[tags_idx] if tags_idx >= 0 and tags_idx < len(row) else '')
tags_list = [t.strip() for t in tags.split(';') if t.strip()]
# Commander detection: prefer filename inference; else best-effort via type line containing 'Commander'
is_commander = (inferred_commander and name == inferred_commander)
if not is_commander:
is_commander = isinstance(type_line, str) and ('commander' in type_line.lower())
if is_commander and not commander_seen:
meta['commander'] = name
commander_seen = True
# Map type_line to broad category
tl = (type_line or '').lower()
if 'battle' in tl:
cat = 'Battle'
elif 'planeswalker' in tl:
cat = 'Planeswalker'
elif 'creature' in tl:
cat = 'Creature'
elif 'instant' in tl:
cat = 'Instant'
elif 'sorcery' in tl:
cat = 'Sorcery'
elif 'artifact' in tl:
cat = 'Artifact'
elif 'enchantment' in tl:
cat = 'Enchantment'
elif 'land' in tl:
cat = 'Land'
else:
cat = 'Other'
# Type counts/cards (exclude commander entry from distribution)
if not is_commander:
type_counts[cat] = type_counts.get(cat, 0) + cnt
type_cards.setdefault(cat, []).append({
'name': name,
'count': cnt,
'role': role,
'tags': tags_list,
})
# Curve
if mv_idx >= 0 and mv_idx < len(row):
bucket = classify_mv(row[mv_idx])
if bucket not in curve_counts:
bucket = '6+'
curve_counts[bucket] += cnt
curve_cards[bucket].append({'name': name, 'count': cnt})
# Colors (from Colors col for commander/overall)
if is_commander and colors_idx >= 0 and colors_idx < len(row):
cid = row[colors_idx] or ''
if isinstance(cid, str):
meta['colors'] = list(cid)
except Exception:
pass
# Precedence ordering
precedence_order = [
'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
]
prec_index = {k: i for i, k in enumerate(precedence_order)}
type_order = sorted(type_counts.keys(), key=lambda k: prec_index.get(k, 999))
summary = {
'type_breakdown': {
'counts': type_counts,
'order': type_order,
'cards': type_cards,
'total': sum(type_counts.values()),
},
'pip_distribution': {
# Not recoverable from CSV without mana symbols; leave zeros
'counts': {c: 0 for c in ('W','U','B','R','G')},
'weights': {c: 0 for c in ('W','U','B','R','G')},
},
'mana_generation': {
# Not recoverable from CSV alone
'W': 0, 'U': 0, 'B': 0, 'R': 0, 'G': 0, 'total_sources': 0,
},
'mana_curve': {
**curve_counts,
'total_spells': sum(curve_counts.values()),
'cards': curve_cards,
},
'colors': meta.get('colors', []),
}
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()
return templates.TemplateResponse("decks/index.html", {"request": request, "items": items})
@router.get("/view", response_class=HTMLResponse)
async def decks_view(request: Request, name: str) -> HTMLResponse:
base = _deck_dir()
p = (base / name).resolve()
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
return templates.TemplateResponse("decks/index.html", {"request": request, "items": _list_decks(), "error": "Deck not found."})
# Try to load sidecar summary JSON first
summary = None
commander_name = ''
tags: List[str] = []
sidecar = p.with_suffix('.summary.json')
if sidecar.exists():
try:
import json as _json
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
if isinstance(payload, dict):
summary = payload.get('summary')
meta = payload.get('meta', {})
if isinstance(meta, dict):
commander_name = meta.get('commander') or ''
_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
if not commander_name:
parts = stem.split('_')
commander_name = parts[0] if parts else ''
ctx = {
"request": request,
"name": p.name,
"csv_path": str(p),
"txt_path": str(txt_path) if txt_path.exists() else None,
"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,
},
)

11
code/web/routes/home.py Normal file
View file

@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from ..app import templates
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request})

282
code/web/routes/owned.py Normal file
View file

@ -0,0 +1,282 @@
from __future__ import annotations
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import HTMLResponse, Response
from ..app import templates
from ..services import owned_store as store
# Session helpers are not required for owned routes
router = APIRouter(prefix="/owned")
def _canon_color_code(seq: list[str] | tuple[str, ...]) -> str:
"""Canonicalize a color identity sequence to a stable code (WUBRG order, no 'C' unless only color)."""
order = {'W':0,'U':1,'B':2,'R':3,'G':4,'C':5}
uniq: list[str] = []
seen: set[str] = set()
for c in (seq or []):
uc = (c or '').upper()
if uc in order and uc not in seen:
seen.add(uc)
uniq.append(uc)
uniq.sort(key=lambda x: order[x])
code = ''.join([c for c in uniq if c != 'C'])
return code or ('C' if 'C' in seen else '')
def _color_combo_label(code: str) -> str:
"""Return friendly label for a 2/3/4-color combo code; empty if unknown.
Uses standard names: Guilds, Shards/Wedges, and Nephilim-style for 4-color.
"""
two_map = {
'WU':'Azorius','UB':'Dimir','BR':'Rakdos','RG':'Gruul','WG':'Selesnya',
'WB':'Orzhov','UR':'Izzet','BG':'Golgari','WR':'Boros','UG':'Simic',
}
three_map = {
'WUB':'Esper','UBR':'Grixis','BRG':'Jund','WRG':'Naya','WUG':'Bant',
'WBR':'Mardu','WUR':'Jeskai','UBG':'Sultai','URG':'Temur','WBG':'Abzan',
}
four_map = {
'WUBR': 'Yore-Tiller', # no G
'WUBG': 'Witch-Maw', # no R
'WURG': 'Ink-Treader', # no B
'WBRG': 'Dune-Brood', # no U
'UBRG': 'Glint-Eye', # no W
}
if len(code) == 2:
return two_map.get(code, '')
if len(code) == 3:
return three_map.get(code, '')
if len(code) == 4:
return four_map.get(code, '')
return ''
def _build_color_combos(names_sorted: list[str], colors_by_name: dict[str, list[str]]) -> list[tuple[str, str]]:
"""Compute present color combos and return [(code, display)], ordered by length then code."""
combo_set: set[str] = set()
for n in names_sorted:
cols = (colors_by_name.get(n) or [])
code = _canon_color_code(cols)
if len(code) >= 2:
combo_set.add(code)
combos: list[tuple[str, str]] = []
for code in sorted(combo_set, key=lambda s: (len(s), s)):
label = _color_combo_label(code)
display = f"{label} ({code})" if label else code
combos.append((code, display))
return combos
def _build_owned_context(request: Request, notice: str | None = None, error: str | None = None) -> dict:
"""Build the template context for the Owned Library page, including
enrichment from csv_files and filter option lists.
"""
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
added_at_map = store.get_added_at_map()
# Default sort by name (case-insensitive)
names_sorted = sorted(names, key=lambda s: s.lower())
# Build filter option sets
all_types = sorted({type_by_name.get(n) for n in names_sorted if type_by_name.get(n)}, key=lambda s: s.lower())
all_tags = sorted({t for n in names_sorted for t in (tags_by_name.get(n) or [])}, key=lambda s: s.lower())
all_colors = ['W','U','B','R','G','C']
# Build color combos displayed in the filter
combos = _build_color_combos(names_sorted, colors_by_name)
ctx = {
"request": request,
"names": names_sorted,
"count": len(names_sorted),
"tags_by_name": tags_by_name,
"type_by_name": type_by_name,
"colors_by_name": colors_by_name,
"all_types": all_types,
"all_tags": all_tags,
"all_colors": all_colors,
"color_combos": combos,
"added_at_map": added_at_map,
}
if notice:
ctx["notice"] = notice
if error:
ctx["error"] = error
return ctx
@router.get("/", response_class=HTMLResponse)
async def owned_index(request: Request) -> HTMLResponse:
ctx = _build_owned_context(request)
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/upload", response_class=HTMLResponse)
async def owned_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
try:
content = await file.read()
fname = (file.filename or "").lower()
if fname.endswith(".csv"):
names = store.parse_csv_bytes(content)
else:
names = store.parse_txt_bytes(content)
# Add and enrich immediately so the page doesn't need to parse CSVs
added, total = store.add_and_enrich(names)
notice = f"Added {added} new name(s). Total: {total}."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Upload failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/clear", response_class=HTMLResponse)
async def owned_clear(request: Request) -> HTMLResponse:
try:
store.clear()
ctx = _build_owned_context(request, notice="Library cleared.")
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Clear failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/remove", response_class=HTMLResponse)
async def owned_remove(request: Request) -> HTMLResponse:
"""Remove a set of names provided as JSON or form data under 'names'."""
try:
names: list[str] = []
# Try JSON first
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
# Fallback to form field 'names' as comma-separated
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
removed, total = store.remove_names(names)
notice = f"Removed {removed} name(s). Total: {total}."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Remove failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
# Bulk user-tag endpoints removed by request.
"""
Note: Per request, all user tag add/remove endpoints have been removed.
"""
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
@router.get("/export")
async def owned_export_txt() -> Response:
"""Download the owned library as a simple TXT (one name per line)."""
names, _, _, _ = store.get_enriched()
# Stable case-insensitive sort
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
return Response(
content=lines + ("\n" if lines else ""),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.txt"},
)
@router.get("/export.csv")
async def owned_export_csv() -> Response:
"""Download the owned library with enrichment as CSV (Name,Type,Colors,Tags)."""
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
# Prepare CSV content
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["Name", "Type", "Colors", "Tags"])
for n in sorted((names or []), key=lambda s: s.lower()):
tline = type_by_name.get(n, "")
cols = ''.join(colors_by_name.get(n, []) or [])
tags = '|'.join(tags_by_name.get(n, []) or [])
writer.writerow([n, tline, cols, tags])
content = buf.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
)
@router.post("/export-visible")
async def owned_export_visible_txt(request: Request) -> Response:
"""Download the provided names (visible subset) as TXT."""
try:
names: list[str] = []
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
# Stable case-insensitive sort
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
return Response(
content=lines + ("\n" if lines else ""),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_visible.txt"},
)
except Exception:
# On error return empty file
return Response(content="", media_type="text/plain; charset=utf-8")
@router.post("/export-visible.csv")
async def owned_export_visible_csv(request: Request) -> Response:
"""Download the provided names (visible subset) with enrichment as CSV."""
try:
names: list[str] = []
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
# Build CSV using current enrichment
all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["Name", "Type", "Colors", "Tags"])
for n in sorted((names or []), key=lambda s: s.lower()):
tline = type_by_name.get(n, "")
cols = ''.join(colors_by_name.get(n, []) or [])
tags = '|'.join(tags_by_name.get(n, []) or [])
writer.writerow([n, tline, cols, tags])
content = buf.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_visible.csv"},
)
except Exception:
return Response(content="", media_type="text/csv; charset=utf-8")

105
code/web/routes/setup.py Normal file
View file

@ -0,0 +1,105 @@
from __future__ import annotations
import threading
from typing import Optional
from fastapi import APIRouter, Request
from fastapi import Body
from pathlib import Path
import json as _json
from fastapi.responses import HTMLResponse, JSONResponse
from ..app import templates
from ..services.orchestrator import _ensure_setup_ready # type: ignore
router = APIRouter(prefix="/setup")
def _kickoff_setup_async(force: bool = False):
def runner():
try:
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
except Exception:
pass
t = threading.Thread(target=runner, daemon=True)
t.start()
@router.get("/running", response_class=HTMLResponse)
async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override]
# Optionally start the setup/tagging in the background if requested
try:
if start and int(start) != 0:
# honor optional force flag from query
f = False
try:
if force is not None:
f = bool(force)
else:
q_force = request.query_params.get('force')
if q_force is not None:
f = q_force.strip().lower() in {"1", "true", "yes", "on"}
except Exception:
f = False
_kickoff_setup_async(force=f)
except Exception:
pass
return templates.TemplateResponse("setup/running.html", {"request": request, "next_url": next})
@router.post("/start")
async def setup_start(request: Request, force: bool = Body(False)): # accept JSON body {"force": true}
try:
# Allow query string override as well (?force=1)
try:
q_force = request.query_params.get('force')
if q_force is not None:
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
except Exception:
pass
# Write immediate status so UI reflects the start
try:
p = Path("csv_files")
p.mkdir(parents=True, exist_ok=True)
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
_json.dump(status, f)
except Exception:
pass
_kickoff_setup_async(force=bool(force))
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
except Exception:
return JSONResponse({"ok": False}, status_code=500)
@router.get("/start")
async def setup_start_get(request: Request):
"""GET alias to start setup/tagging via query string (?force=1).
Useful as a fallback from clients that cannot POST JSON.
"""
try:
# Determine force from query params
force = False
try:
q_force = request.query_params.get('force')
if q_force is not None:
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
except Exception:
pass
# Write immediate status so UI reflects the start
try:
p = Path("csv_files")
p.mkdir(parents=True, exist_ok=True)
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
_json.dump(status, f)
except Exception:
pass
_kickoff_setup_async(force=bool(force))
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
except Exception:
return JSONResponse({"ok": False}, status_code=500)
@router.get("/", response_class=HTMLResponse)
async def setup_index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("setup/index.html", {"request": request})

View file

@ -0,0 +1 @@
# Services package marker

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,493 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Tuple, Dict
import json
import os
import time
def _owned_dir() -> Path:
"""Resolve the owned cards directory (shared with CLI) for persistence.
Precedence:
- OWNED_CARDS_DIR env var
- CARD_LIBRARY_DIR env var (back-compat)
- ./owned_cards (if exists)
- ./card_library (if exists)
- default ./owned_cards
"""
env_dir = os.getenv("OWNED_CARDS_DIR") or os.getenv("CARD_LIBRARY_DIR")
if env_dir:
return Path(env_dir).resolve()
for name in ("owned_cards", "card_library"):
p = Path(name)
if p.exists() and p.is_dir():
return p.resolve()
return Path("owned_cards").resolve()
def _db_path() -> Path:
d = _owned_dir()
try:
d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return (d / ".web_owned_db.json").resolve()
def _load_raw() -> dict:
p = _db_path()
if p.exists():
try:
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
# Back-compat defaults
if "names" not in data or not isinstance(data.get("names"), list):
data["names"] = []
if "meta" not in data or not isinstance(data.get("meta"), dict):
data["meta"] = {}
return data
except Exception:
return {"names": [], "meta": {}}
return {"names": [], "meta": {}}
def _save_raw(data: dict) -> None:
p = _db_path()
try:
with p.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
pass
def get_names() -> List[str]:
data = _load_raw()
names = data.get("names") or []
if not isinstance(names, list):
return []
# Normalize and dedupe while preserving stable ordering
seen = set()
out: List[str] = []
for n in names:
s = str(n).strip()
if not s:
continue
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s)
return out
def clear() -> None:
_save_raw({"names": [], "meta": {}})
def add_names(names: Iterable[str]) -> Tuple[int, int]:
"""Add a batch of names; returns (added_count, total_after)."""
data = _load_raw()
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
cur_set = {n.lower() for n in cur}
added = 0
for raw in names:
try:
s = str(raw).strip()
if not s:
continue
key = s.lower()
if key in cur_set:
continue
cur.append(s)
cur_set.add(key)
added += 1
except Exception:
continue
data["names"] = cur
if "meta" not in data or not isinstance(data.get("meta"), dict):
data["meta"] = {}
meta = data["meta"]
now = int(time.time())
# Ensure newly added names have an added_at
for s in cur:
info = meta.get(s)
if not info:
meta[s] = {"added_at": now}
else:
if "added_at" not in info:
info["added_at"] = now
_save_raw(data)
return added, len(cur)
def _enrich_from_csvs(target_names: Iterable[str]) -> Dict[str, Dict[str, object]]:
"""Return metadata for target names by scanning csv_files/*_cards.csv.
Output: { Name: { 'tags': [..], 'type': str|None, 'colors': [..] } }
"""
from pathlib import Path
import json as _json
import csv as _csv
base = Path('csv_files')
meta: Dict[str, Dict[str, object]] = {}
want = {str(n).strip().lower() for n in target_names if str(n).strip()}
if not (base.exists() and want):
return meta
csv_files = [p for p in base.glob('*_cards.csv') if p.name.lower() not in ('cards.csv', 'commander_cards.csv')]
def _norm(s: str) -> str: return str(s or '').strip().lower()
for path in csv_files:
try:
with path.open('r', encoding='utf-8', errors='ignore') as f:
reader = _csv.DictReader(f)
headers = [h for h in (reader.fieldnames or [])]
name_key = None
tags_key = None
type_key = None
colors_key = None
for h in headers:
hn = _norm(h)
if hn in ('name', 'card', 'cardname', 'card_name'):
name_key = h
if hn in ('tags', 'theme_tags', 'themetags', 'themetagsjson') or hn == 'themetags' or hn == 'themetagsjson':
tags_key = h
if hn in ('type', 'type_line', 'typeline'):
type_key = h
if hn in ('colors', 'coloridentity', 'color_identity', 'color'):
colors_key = h
if not tags_key:
for h in headers:
if h.strip() in ('ThemeTags', 'themeTags'):
tags_key = h
break
if not colors_key:
for h in headers:
if h.strip() in ('ColorIdentity', 'colorIdentity'):
colors_key = h
break
if not name_key:
continue
for row in reader:
try:
nm = str(row.get(name_key) or '').strip()
if not nm:
continue
low = nm.lower()
if low not in want:
continue
entry = meta.setdefault(nm, {"tags": [], "type": None, "colors": []})
# Tags
if tags_key:
raw = (row.get(tags_key) or '').strip()
vals: List[str] = []
if raw:
if raw.startswith('['):
try:
arr = _json.loads(raw)
if isinstance(arr, list):
vals = [str(x).strip() for x in arr if str(x).strip()]
except Exception:
vals = []
if not vals:
parts = [p.strip() for p in raw.replace(';', ',').split(',')]
vals = [p for p in parts if p]
if vals:
existing = entry.get('tags') or []
seen = {str(t).lower() for t in existing}
for t in vals:
if str(t).lower() not in seen:
existing.append(str(t))
seen.add(str(t).lower())
entry['tags'] = existing
# Type
if type_key and not entry.get('type'):
t_raw = str(row.get(type_key) or '').strip()
if t_raw:
tline = t_raw.split('')[0].strip() if '' in t_raw else t_raw
prim = None
for cand in ['Creature','Instant','Sorcery','Artifact','Enchantment','Planeswalker','Land','Battle']:
if cand.lower() in tline.lower():
prim = cand
break
if not prim and tline:
prim = tline.split()[0]
if prim:
entry['type'] = prim
# Colors
if colors_key and not entry.get('colors'):
c_raw = str(row.get(colors_key) or '').strip()
cols: List[str] = []
if c_raw:
if c_raw.startswith('['):
try:
arr = _json.loads(c_raw)
if isinstance(arr, list):
cols = [str(x).strip().upper() for x in arr if str(x).strip()]
except Exception:
cols = []
if not cols:
parts = [p.strip().upper() for p in c_raw.replace(';', ',').replace('[','').replace(']','').replace("'",'').split(',') if p.strip()]
if parts:
cols = parts
if not cols:
for ch in c_raw:
if ch.upper() in ('W','U','B','R','G','C'):
cols.append(ch.upper())
if cols:
seen_c = set()
uniq = []
for c in cols:
if c not in seen_c:
uniq.append(c)
seen_c.add(c)
entry['colors'] = uniq
except Exception:
continue
except Exception:
continue
return meta
def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]:
"""Add names and enrich their metadata from CSVs in one pass.
Returns (added_count, total_after).
"""
data = _load_raw()
current_names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
cur_set = {n.lower() for n in current_names}
new_names: List[str] = []
for raw in names:
try:
s = str(raw).strip()
if not s:
continue
key = s.lower()
if key in cur_set:
continue
current_names.append(s)
cur_set.add(key)
new_names.append(s)
except Exception:
continue
# Enrich
meta = data.get("meta") or {}
now = int(time.time())
if new_names:
enriched = _enrich_from_csvs(new_names)
for nm, info in enriched.items():
meta[nm] = info
# Stamp added_at for new names if missing
for nm in new_names:
entry = meta.setdefault(nm, {})
if "added_at" not in entry:
entry["added_at"] = now
data["names"] = current_names
data["meta"] = meta
_save_raw(data)
return len(new_names), len(current_names)
def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dict[str, List[str]]]:
"""Return names and metadata dicts (tags_by_name, type_by_name, colors_by_name).
If metadata missing, returns empty for those entries.
"""
data = _load_raw()
names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
tags_by_name: Dict[str, List[str]] = {}
type_by_name: Dict[str, str] = {}
colors_by_name: Dict[str, List[str]] = {}
for n in names:
info = meta.get(n) or {}
tags = (info.get('tags') or [])
# user-defined tags are no longer supported; no merge
typ = info.get('type') or None
cols = info.get('colors') or []
if tags:
tags_by_name[n] = [str(x) for x in tags if str(x)]
if typ:
type_by_name[n] = str(typ)
if cols:
colors_by_name[n] = [str(x).upper() for x in cols if str(x)]
return names, tags_by_name, type_by_name, colors_by_name
# add_user_tag/remove_user_tag removed; user-defined tags are not persisted anymore
def get_added_at_map() -> Dict[str, int]:
"""Return a mapping of name -> added_at unix timestamp (if known)."""
data = _load_raw()
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
out: Dict[str, int] = {}
for n, info in meta.items():
try:
ts = info.get("added_at")
if isinstance(ts, (int, float)):
out[n] = int(ts)
except Exception:
continue
return out
def remove_names(names: Iterable[str]) -> Tuple[int, int]:
"""Remove a batch of names; returns (removed_count, total_after)."""
target = {str(n).strip().lower() for n in (names or []) if str(n).strip()}
if not target:
return 0, len(get_names())
data = _load_raw()
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
before = len(cur)
cur_kept: List[str] = []
for s in cur:
if s.lower() in target:
continue
cur_kept.append(s)
removed = before - len(cur_kept)
data["names"] = cur_kept
meta = data.get("meta") or {}
# Drop meta entries for removed names
for s in list(meta.keys()):
try:
if s.lower() in target:
meta.pop(s, None)
except Exception:
continue
data["meta"] = meta
_save_raw(data)
return removed, len(cur_kept)
def get_user_tags_map() -> Dict[str, list[str]]:
"""Deprecated: user-defined tags have been removed. Always returns empty mapping."""
return {}
def parse_txt_bytes(content: bytes) -> List[str]:
out: List[str] = []
try:
text = content.decode("utf-8", errors="ignore")
except Exception:
text = content.decode(errors="ignore")
for line in text.splitlines():
s = (line or "").strip()
if not s or s.startswith("#") or s.startswith("//"):
continue
parts = s.split()
if len(parts) >= 2 and (parts[0].isdigit() or (parts[0].lower().endswith('x') and parts[0][:-1].isdigit())):
s = ' '.join(parts[1:])
if s:
out.append(s)
return out
def parse_csv_bytes(content: bytes) -> List[str]:
names: List[str] = []
try:
import csv
from io import StringIO
import re
text = content.decode("utf-8", errors="ignore")
f = StringIO(text)
try:
reader = csv.DictReader(f)
headers = [h for h in (reader.fieldnames or []) if isinstance(h, str)]
# Normalize headers: lowercase and remove non-letters (spaces, underscores, dashes)
def norm(h: str) -> str:
return re.sub(r"[^a-z]", "", (h or "").lower())
# Map normalized -> original header
norm_map = {norm(h): h for h in headers}
# Preferred keys (exact normalized match)
preferred = ["name", "cardname"]
key = None
for k in preferred:
if k in norm_map:
key = norm_map[k]
break
# Fallback: allow plain 'card' but avoid 'cardnumber', 'cardid', etc.
if key is None:
if "card" in norm_map and all(x not in norm_map for x in ("cardnumber", "cardno", "cardid", "collectornumber", "collector", "multiverseid")):
key = norm_map["card"]
# Another fallback: try common variants if not strictly normalized
if key is None:
for h in headers:
h_clean = (h or "").strip().lower()
if h_clean in ("name", "card name", "card_name", "cardname"):
key = h
break
if key:
for row in reader:
val = str(row.get(key) or '').strip()
if not val:
continue
names.append(val)
else:
f.seek(0)
reader2 = csv.reader(f)
rows = list(reader2)
if not rows:
pass
else:
# Try to detect a likely name column from the first row
header = rows[0]
name_col = 0
if header:
# Look for header cells resembling name
for idx, cell in enumerate(header):
c = str(cell or '').strip()
cn = norm(c)
if cn in ("name", "cardname"):
name_col = idx
break
else:
# As a fallback, if any cell lower is exactly 'card', take it
for idx, cell in enumerate(header):
c = str(cell or '').strip().lower()
if c == 'card':
name_col = idx
break
# Iterate rows, skip header-like first row when it matches
for i, row in enumerate(rows):
if not row:
continue
if i == 0:
first = str(row[name_col] if len(row) > name_col else '').strip()
fn = norm(first)
if fn in ("name", "cardname") or first.lower() in ("name", "card name", "card", "card_name"):
continue # skip header
val = str(row[name_col] if len(row) > name_col else '').strip()
if not val:
continue
# Skip rows that look like header or counts
low = val.lower()
if low in ("name", "card name", "card", "card_name"):
continue
names.append(val)
except Exception:
# Fallback: one name per line
f.seek(0)
for line in f:
s = (line or '').strip()
if s and s.lower() not in ('name', 'card', 'card name'):
names.append(s)
except Exception:
pass
# Normalize, dedupe while preserving order
seen = set()
out: List[str] = []
for n in names:
s = str(n).strip()
if not s:
continue
k = s.lower()
if k in seen:
continue
seen.add(k)
out.append(s)
return out

View file

@ -0,0 +1,48 @@
from __future__ import annotations
import time
import uuid
from typing import Dict, Any, Optional
# Extremely simple in-memory session/task store for MVP
_SESSIONS: Dict[str, Dict[str, Any]] = {}
_TTL_SECONDS = 60 * 60 * 8 # 8 hours
def new_sid() -> str:
return uuid.uuid4().hex
def touch_session(sid: str) -> Dict[str, Any]:
now = time.time()
s = _SESSIONS.get(sid)
if not s:
s = {"created": now, "updated": now}
_SESSIONS[sid] = s
else:
s["updated"] = now
return s
def get_session(sid: Optional[str]) -> Dict[str, Any]:
if not sid:
sid = new_sid()
return touch_session(sid)
def set_session_value(sid: str, key: str, value: Any) -> None:
touch_session(sid)[key] = value
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
return touch_session(sid).get(key, default)
def cleanup_expired() -> None:
now = time.time()
expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS]
for sid in expired:
try:
del _SESSIONS[sid]
except Exception:
pass

666
code/web/static/app.js Normal file
View file

@ -0,0 +1,666 @@
/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
(function(){
// Design tokens fallback (in case CSS variables missing in older browsers)
// No-op here since styles.css defines variables; kept for future JS reads.
// State persistence helpers (localStorage + URL hash)
var state = {
get: function(key, def){
try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
},
set: function(key, val){
try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
},
inHash: function(obj){
// Merge obj into location.hash as query-like params
try {
var params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); });
location.hash = params.toString();
} catch(e){}
},
readHash: function(){
try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
}
};
window.__mtgState = state;
// Toast system
var toastHost;
function ensureToastHost(){
if (!toastHost){
toastHost = document.createElement('div');
toastHost.className = 'toast-host';
document.body.appendChild(toastHost);
}
return toastHost;
}
function toast(msg, type, opts){
ensureToastHost();
var t = document.createElement('div');
t.className = 'toast' + (type ? ' '+type : '');
t.setAttribute('role','status');
t.setAttribute('aria-live','polite');
t.textContent = '';
if (typeof msg === 'string') { t.textContent = msg; }
else if (msg && msg.nodeType === 1) { t.appendChild(msg); }
toastHost.appendChild(t);
var delay = (opts && opts.duration) || 2600;
setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
return t;
}
window.toast = toast;
function toastHTML(html, type, opts){
var container = document.createElement('div');
container.innerHTML = html;
return toast(container, type, opts);
}
window.toastHTML = toastHTML;
// Global HTMX error handling => toast
document.addEventListener('htmx:responseError', function(e){
var detail = e.detail || {}; var xhr = detail.xhr || {};
var rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || '';
var payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })();
var status = payload.status || xhr.status || '';
var msg = payload.detail || payload.message || 'Action failed';
var path = payload.path || (e && e.detail && e.detail.path) || '';
var html = ''+
'<div style="display:flex; align-items:center; gap:.5rem">'+
'<span style="font-weight:600">'+String(msg)+'</span>'+ (status? ' <span class="muted">('+status+')</span>' : '')+
(rid ? '<button class="btn small" style="margin-left:auto" type="button" data-copy-error>Copy details</button>' : '')+
'</div>'+
(rid ? '<div class="muted" style="font-size:11px; margin-top:2px">Request-ID: <code>'+rid+'</code></div>' : '');
var t = toastHTML(html, 'error', { duration: 7000 });
// Wire Copy
var btn = t.querySelector('[data-copy-error]');
if (btn){
btn.addEventListener('click', function(){
var lines = [
'Error: '+String(msg),
'Status: '+String(status),
'Path: '+String(path || (xhr.responseURL||'')),
'Request-ID: '+String(rid)
];
try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ }
});
}
// Optional inline banner if a surface is available
try {
var target = e && e.target;
var surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]');
if (surface){
var banner = document.createElement('div');
banner.className = 'inline-error-banner';
banner.innerHTML = '<strong>'+String(msg)+'</strong>' + (rid? ' <span class="muted">(Request-ID: '+rid+')</span>' : '');
surface.prepend(banner);
setTimeout(function(){ banner.remove(); }, 8000);
}
} catch(_){ }
});
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
// Keyboard shortcuts
var keymap = {
' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); },
'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); },
'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); },
'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); },
};
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](); }
});
// Focus ring visibility for keyboard nav
function addFocusVisible(){
var hadKeyboardEvent = false;
function onKeyDown(){ hadKeyboardEvent = true; }
function onPointer(){ hadKeyboardEvent = false; }
function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); }
function onBlur(e){ e.target.classList.remove('focus-visible'); }
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('mousedown', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('touchstart', onPointer, true);
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onBlur);
}
addFocusVisible();
// Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
function showSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.add('is-loading'); });
}
function hideSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.remove('is-loading'); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
var el = e.target;
if (el && el.matches('[data-pref]')){
var key = el.getAttribute('data-pref');
var val = (el.type === 'checkbox') ? !!el.checked : el.value;
state.set(key, val);
state.inHash((function(o){ o[key] = val; return o; })({}));
}
});
// On load, initialize any data-pref elements
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('[data-pref]').forEach(function(el){
var key = el.getAttribute('data-pref');
var saved = state.get(key, undefined);
if (typeof saved !== 'undefined'){
if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved;
}
});
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
initVirtualization(document);
});
// Hydrate progress bars with width based on data-pct
function hydrateProgress(root){
(root || document).querySelectorAll('.progress[data-pct]')
.forEach(function(p){
var pct = parseInt(p.getAttribute('data-pct') || '0', 10);
if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
var bar = p.querySelector('.bar'); if (!bar) return;
// Animate width for a bit of delight
requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
});
}
// Keep hidden inputs for show_skipped in sync with the sticky checkbox
function syncShowSkipped(root){
var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
if (!cb) return;
var val = cb.checked ? '1' : '0';
(root || document).querySelectorAll('section form').forEach(function(f){
var h = f.querySelector('input[name="show_skipped"]');
if (h) h.value = val;
});
}
document.addEventListener('htmx:afterSwap', function(e){
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');
if (!section) return;
var toolbar = section.querySelector('.cards-toolbar');
if (!toolbar) return; // nothing to do
var q = toolbar.querySelector('input[name="filter_query"]');
var ownedSel = toolbar.querySelector('select[name="filter_owned"]');
var showReasons = toolbar.querySelector('input[name="show_reasons"]');
var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
var resultsEl = toolbar.querySelector('[data-results]');
var emptyEl = section.querySelector('[data-empty]');
var sortSel = toolbar.querySelector('select[name="filter_sort"]');
var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
var chipNot = toolbar.querySelector('[data-chip-owned="not"]');
var chipAll = toolbar.querySelector('[data-chip-owned="all"]');
var chipClear = toolbar.querySelector('[data-chip-clear]');
function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
// Read URL hash on first init to hydrate controls
try {
var params = window.__mtgState.readHash();
if (params){
var hv = params.get('q'); if (q && hv !== null) q.value = hv;
hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
}
} catch(_){}
function apply(){
var query = (getVal(q)+ '').toLowerCase().trim();
var ownedMode = (getVal(ownedSel) || 'all');
var showR = !!getVal(showReasons);
var collapse = !!getVal(collapseGroups);
var sortMode = (getVal(sortSel) || 'az');
// Toggle reasons visibility via section class
section.classList.toggle('hide-reasons', !showR);
// Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid'); if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
if (collapse){
grid.setAttribute('data-collapsed','1');
} else {
// restore stored
if (key){
var stored = state.get('cards:group:'+key, null);
if (stored === true){ grid.setAttribute('data-collapsed','1'); }
else { grid.removeAttribute('data-collapsed'); }
} else {
grid.removeAttribute('data-collapsed');
}
}
});
// Filter tiles
var tiles = section.querySelectorAll('.card-grid .card-tile');
var visible = 0;
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
var role = (tile.getAttribute('data-role')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var owned = tile.getAttribute('data-owned') === '1';
var text = name + ' ' + role + ' ' + tags;
var qOk = !query || text.indexOf(query) !== -1;
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
var show = qOk && oOk;
tile.style.display = show ? '' : 'none';
if (show) visible++;
});
// Sort within each grid
function keyFor(tile){
var name = (tile.getAttribute('data-card-name')||'');
var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
var gc = tile.classList.contains('game-changer') ? 1 : 0;
return { name: name.toLowerCase(), owned: owned, gc: gc };
}
section.querySelectorAll('.card-grid').forEach(function(grid){
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
arr.sort(function(a,b){
var ka = keyFor(a), kb = keyFor(b);
if (sortMode === 'owned'){
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
return ka.name.localeCompare(kb.name);
} else if (sortMode === 'gc'){
if (kb.gc !== ka.gc) return kb.gc - ka.gc;
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
return ka.name.localeCompare(kb.name);
}
// default AZ
return ka.name.localeCompare(kb.name);
});
arr.forEach(function(el){ grid.appendChild(el); });
});
// Update group counts based on visible tiles within each group
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid');
var count = 0;
if (grid){
grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
}
var cEl = wrapper.querySelector('[data-count]');
if (cEl) cEl.textContent = count;
});
if (resultsEl) resultsEl.textContent = String(visible);
if (emptyEl) emptyEl.hidden = (visible !== 0);
// Persist prefs
if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
// Update URL hash for shareability
try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
}
// Wire events
if (q) q.addEventListener('input', apply);
if (ownedSel) ownedSel.addEventListener('change', apply);
if (showReasons) showReasons.addEventListener('change', apply);
if (collapseGroups) collapseGroups.addEventListener('change', apply);
if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
// Individual group toggles
section.querySelectorAll('.group-header .toggle').forEach(function(btn){
btn.addEventListener('click', function(){
var wrapper = btn.closest('.group');
var grid = wrapper && wrapper.querySelector('.group-grid');
if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
var willCollapse = !grid.getAttribute('data-collapsed');
if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
if (key){ state.set('cards:group:'+key, !!willCollapse); }
// ARIA
btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
});
});
// Per-card reason toggle: delegate clicks on .btn-why
section.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.classList || !t.classList.contains('btn-why')) return;
e.preventDefault();
var tile = t.closest('.card-tile');
if (!tile) return;
var globalHidden = section.classList.contains('hide-reasons');
if (globalHidden){
// Force-show overrides global hidden
var on = tile.classList.toggle('force-show');
if (on) tile.classList.remove('force-hide');
t.textContent = on ? 'Hide why' : 'Why?';
} else {
// Hide this tile only
var off = tile.classList.toggle('force-hide');
if (off) tile.classList.remove('force-show');
t.textContent = off ? 'Show why' : 'Hide why';
}
});
// Initial apply on hydrate
apply();
// Keyboard helpers: '/' focuses query, Esc clears
function onKey(e){
// avoid when typing in inputs
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/'){
if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
} else if (e.key === 'Escape'){
if (q && q.value){ q.value=''; apply(); }
}
}
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(_){ }
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
code/web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -0,0 +1,13 @@
{
"name": "MTG Deckbuilder",
"short_name": "Deckbuilder",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f0f10",
"theme_color": "#0f0f10",
"icons": [
{ "src": "/static/favicon.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/favicon.png", "sizes": "512x512", "type": "image/png" }
]
}

311
code/web/static/styles.css Normal file
View file

@ -0,0 +1,311 @@
/* Base */
:root{
/* MTG color palette (approx from provided values) */
--banner-h: 52px;
--sidebar-w: 260px;
--green-main: rgb(0,115,62);
--green-light: rgb(196,211,202);
--blue-main: rgb(14,104,171);
--blue-light: rgb(179,206,234);
--red-main: rgb(211,32,42);
--red-light: rgb(235,159,130);
--white-main: rgb(249,250,244);
--white-light: rgb(248,231,185);
--black-main: rgb(21,11,0);
--black-light: rgb(166,159,157);
--bg: #0f0f10;
--panel: #1a1b1e;
--text: #e8e8e8;
--muted: #b6b8bd;
--border: #2a2b2f;
--ring: #60a5fa; /* focus ring */
--ok: #16a34a; /* success */
--warn: #f59e0b; /* warning */
--err: #ef4444; /* error */
/* Surface overrides for specific regions (default to panel) */
--surface-banner: var(--panel);
--surface-banner-text: var(--text);
--surface-sidebar: var(--panel);
--surface-sidebar-text: var(--text);
}
/* Light blend between Slate and Parchment (leans gray) */
[data-theme="light-blend"]{
--bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
--panel: #ffffff; /* crisp panels for readability */
--text: #0b0d12;
--muted: #6b655d; /* slightly warm muted */
--border: #d6d1c7; /* neutral warm-gray border */
/* Slightly darker banner/sidebar for separation */
--surface-banner: #1a1b1e;
--surface-sidebar: #1a1b1e;
--surface-banner-text: #e8e8e8;
--surface-sidebar-text: #e8e8e8;
}
[data-theme="dark"]{
--bg: #0f0f10;
--panel: #1a1b1e;
--text: #e8e8e8;
--muted: #b6b8bd;
--border: #2a2b2f;
}
[data-theme="high-contrast"]{
--bg: #000;
--panel: #000;
--text: #fff;
--muted: #e5e7eb;
--border: #fff;
--ring: #ff0;
}
[data-theme="cb-friendly"]{
/* Tweak accents for color-blind friendliness */
--green-main: #2e7d32; /* darker green */
--red-main: #c62828; /* deeper red */
--blue-main: #1565c0; /* balanced blue */
}
*{box-sizing:border-box}
html,body{height:100%}
body {
font-family: system-ui, Arial, sans-serif;
margin: 0;
color: var(--text);
background: var(--bg);
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
/* Accessible focus ring for keyboard navigation */
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* Top banner */
.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
.top-banner{ min-height: var(--banner-h); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.banner-status.busy{ color:#fbbf24; }
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
/* Layout */
.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
.sidebar{
background: var(--surface-sidebar);
color: var(--surface-sidebar-text);
border-right: 1px solid var(--border);
padding: 1rem;
position: fixed;
top: var(--banner-h);
left: 0;
bottom: 0;
overflow: auto;
width: var(--sidebar-w);
z-index: 9; /* below the banner (z=10) */
box-shadow: 2px 0 10px rgba(0,0,0,.18);
}
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
.brand h1{ display:none; }
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
.dot.green{ background: var(--green-main); }
.dot.blue{ background: var(--blue-main); }
.dot.red{ background: var(--red-main); }
.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
.dot.black{ background: var(--black-light); }
.nav{ display:flex; flex-direction:column; gap:.35rem; }
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
/* Simple two-column layout for inspect panel */
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
.two-col .grow { min-width: 0; }
.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); }
@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
/* Left-rail variant puts the image first */
.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
.card-preview.card-sm{ max-width:200px; }
/* Buttons, inputs */
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
button:hover{ filter:brightness(1.05); }
/* Anchor-style buttons */
.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }
/* Toasts */
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
.toast.hide{ opacity:0; transform: translateY(6px); }
.toast.success{ border-color: rgba(22,163,74,.4); }
.toast.error{ border-color: rgba(239,68,68,.45); }
.toast.warn{ border-color: rgba(245,158,11,.45); }
/* Skeletons */
[data-skeleton]{ position: relative; }
[data-skeleton].is-loading > *{ opacity: 0; }
[data-skeleton]::after{
content: '';
position: absolute; inset: 0;
border-radius: 8px;
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
background-size: 200% 100%;
animation: shimmer 1.1s linear infinite;
display: none;
}
[data-skeleton].is-loading::after{ display:block; }
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
.banner .subtitle{ color: var(--muted); font-size:.95rem; }
/* Home actions */
.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); }
.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
/* Card grid for added cards (responsive, compact tiles) */
.card-grid{
display:grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
gap: .5rem;
margin-top:.5rem;
justify-content: start; /* pack as many as possible per row */
}
.card-tile{
width:170px;
position: relative;
background: var(--panel);
border:1px solid var(--border);
border-radius:6px;
padding:.25rem .25rem .4rem;
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; }
/* Shared ownership badge for card tiles and stacked images */
.owned-badge{
position:absolute;
top:6px;
left:6px;
background:rgba(17,24,39,.9);
color:#e5e7eb;
border:1px solid var(--border);
border-radius:12px;
font-size:12px;
line-height:18px;
height:18px;
min-width:18px;
padding:0 6px;
text-align:center;
pointer-events:none;
z-index:2;
}
/* Step 1 candidate grid (200px-wide scaled images) */
.candidate-grid{
display:grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap:.75rem;
}
.candidate-tile{
background: var(--panel);
border:1px solid var(--border);
border-radius:8px;
padding:.4rem;
}
.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; }
.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
.candidate-tile .name{ font-weight:600; font-size:.95rem; }
.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
/* Deck summary: highlight game changers */
.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; }
.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
.stage-nav .stage-item.done .stage-link { opacity:.75; }
.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks for small screens */
@media (max-width: 720px){
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
}
/* Progress bar */
.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
/* Chips */
.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
/* Cards toolbar */
.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
.cards-toolbar input[type="text"]{ min-width: 220px; }
.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
/* Collapse groups and reason toggle */
.group{ margin:.5rem 0; }
.group-header{ display:flex; align-items:center; gap:.5rem; }
.group-header h5{ margin:.4rem 0; }
.group-header .count{ color: var(--muted); font-size:12px; }
.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
.group-grid[data-collapsed]{ display:none; }
.hide-reasons .card-tile .reason{ display:none; }
.card-tile.force-show .reason{ display:block !important; }
.card-tile.force-hide .reason{ display:none !important; }
.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
.chips-inline .chip{ cursor:pointer; user-select:none; }
/* Inline error banner */
.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; 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; }

10
code/web/static/sw.js Normal file
View file

@ -0,0 +1,10 @@
// Minimal service worker (stub). Controlled by ENABLE_PWA.
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', event => {
// Pass-through; caching strategy can be added later.
});

View file

@ -0,0 +1,3 @@
/* Local fallback for HTMX 1.9.12. If the CDN fails, base.html will load this file. */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t():("function"==typeof define&&define.amd?define(t):t())}(0,function(){/* placeholder minimal shim to avoid runtime errors if CDN blocked; swaps won't work until user refreshes with network */
window.htmx=window.htmx||{version:"1.9.12",onLoad:function(){},find:function(){return null},trigger:function(){},config:{},logAll:function(){}};});

View file

@ -0,0 +1,431 @@
<!doctype html>
<html lang="en" data-theme="{% if default_theme == 'light' %}light-blend{% elif default_theme == 'dark' %}dark{% else %}light-blend{% endif %}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<script>
(function(){
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
try{
var root = document.documentElement;
var KEY = 'mtg:theme';
var SERVER_DEFAULT = '{{ default_theme }}';
var params = new URLSearchParams(window.location.search || '');
var urlTheme = (params.get('theme') || '').toLowerCase();
var stored = localStorage.getItem(KEY);
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function mapTheme(v){
var x = (v || 'system').toLowerCase();
if (x === 'system') return resolveSystem();
if (x === 'light') return 'light-blend';
return x;
}
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
root.setAttribute('data-theme', mapTheme(initial));
}catch(_){ }
})();
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
<!-- 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" />
{% if enable_pwa %}
<link rel="manifest" href="/static/manifest.webmanifest" />
{% endif %}
</head>
<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" 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>
{% if enable_themes %}
<label style="margin:0 .5rem; align-items:flex-start; margin-left:auto">
<span class="muted" style="font-size:11px">Theme</span>
<select id="theme-select" aria-label="Theme selector">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="high-contrast">High contrast</option>
<option value="cb-friendly">Color-blind</option>
</select>
</label>
<button type="button" id="theme-reset" class="btn" title="Reset theme preference" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
Reset
</button>
{% endif %}
</div>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="brand">
<div class="mana-dots" aria-hidden="true">
<span class="dot green"></span>
<span class="dot blue"></span>
<span class="dot red"></span>
<span class="dot white"></span>
<span class="dot black"></span>
</div>
</div>
<nav class="nav">
<a href="/">Home</a>
<a href="/build">Build</a>
<a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
<a href="/decks">Finished Decks</a>
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
</aside>
<main class="content" data-error-surface>
{% block content %}{% endblock %}
</main>
</div>
<footer class="site-footer" role="contentinfo">
Card images and data provided by
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
</footer>
<style>
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); }
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
.card-meta li { margin:.1rem 0; }
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
.card-meta .line + .line { margin-top:.35rem; }
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
footer.site-footer { flex-shrink: 0; }
</style>
<script>
(function(){
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){
if (!statusEl) statusEl = document.getElementById('banner-status');
return statusEl;
}
function renderSetupStatus(data){
var el = ensureStatusEl(); if (!el) return;
if (data && data.running) {
var msg = (data.message || 'Preparing data...');
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
el.classList.add('busy');
} else if (data && data.phase === 'done') {
el.innerHTML = '<span class="muted">Setup complete.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
} else if (data && data.phase === 'error') {
el.innerHTML = '<span class="error">Setup error.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
} else {
if (!el.innerHTML.trim()) el.innerHTML = '';
el.classList.remove('busy');
}
}
function pollStatus(){
try {
fetch('/status/setup', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderSetupStatus)
.catch(function(){ /* noop */ });
} catch(e) {}
}
setInterval(pollStatus, 3000);
pollStatus();
// Health indicator poller
var healthDot = document.getElementById('health-dot');
function renderHealth(data){
if (!healthDot) return;
var ok = data && data.status === 'ok';
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
}
function pollHealth(){
try {
fetch('/healthz', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderHealth)
.catch(function(){ renderHealth({ status: 'bad' }); });
} catch(e){ renderHealth({ status: 'bad' }); }
}
setInterval(pollHealth, 5000);
pollHealth();
function ensureCard() {
var pop = document.getElementById('card-hover');
if (!pop) {
pop = document.createElement('div');
pop.id = 'card-hover';
pop.className = 'card-hover';
var inner = document.createElement('div');
inner.className = 'card-hover-inner';
var img = document.createElement('img');
img.alt = 'Card preview';
var meta = document.createElement('div');
meta.className = 'card-meta';
inner.appendChild(img);
inner.appendChild(meta);
pop.appendChild(inner);
document.body.appendChild(pop);
}
return pop;
}
var cardPop = ensureCard();
var PREVIEW_VERSIONS = ['normal','large'];
function buildCardUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Generic Scryfall image URL builder
function buildScryfallImageUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Global image retry binding for any <img data-card-name>
var IMG_FLAG = '__cardImgRetry';
function bindCardImageRetry(img, versions){
try {
if (!img || img[IMG_FLAG]) return;
var name = img.getAttribute('data-card-name') || '';
if (!name) return;
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
img.addEventListener('error', function(){
var st = img[IMG_FLAG];
if (!st) return;
if (st.vi < st.versions.length - 1){
st.vi += 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else if (!st.nocache){
st.nocache = 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
}
});
// If the initial load already failed before binding, try the next immediately
if (img.complete && img.naturalWidth === 0){
// If src corresponds to the first version, move to next; else, just force a reload
var st = img[IMG_FLAG];
var current = img.src || '';
var first = buildScryfallImageUrl(name, st.versions[0], false);
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
st.vi = Math.min(1, st.versions.length - 1);
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else {
// Re-trigger current request (may succeed if transient)
img.src = current;
}
}
} catch(_){}
}
function bindAllCardImageRetries(){
document.querySelectorAll('img[data-card-name]').forEach(function(img){
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
bindCardImageRetry(img, versions);
});
}
function positionCard(e) {
var x = e.clientX + 16, y = e.clientY + 16;
cardPop.style.display = 'block';
cardPop.style.left = x + 'px';
cardPop.style.top = y + 'px';
var rect = cardPop.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
}
function attachCardHover() {
document.querySelectorAll('[data-card-name]').forEach(function(el) {
if (el.__cardHoverBound) return; // avoid duplicate bindings
el.__cardHoverBound = true;
el.addEventListener('mouseenter', function(e) {
var img = cardPop.querySelector('img');
var meta = cardPop.querySelector('.card-meta');
var name = el.getAttribute('data-card-name') || '';
var vi = 0; // always start at 'normal' on hover
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
// Bind a one-off error handler per enter to try fallbacks
var triedNoCache = false;
function onErr(){
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
else { img.removeEventListener('error', onErr); }
}
img.addEventListener('error', onErr, { once:false });
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
var role = el.getAttribute('data-role') || '';
var rawTags = el.getAttribute('data-tags') || '';
// Clean and split tags into an array; remove brackets and quotes
var tags = rawTags
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
.split(/\s*,\s*/)
.filter(function(t){ return t && t.trim(); });
if (role || (tags && tags.length)) {
var html = '';
if (role) {
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'&lt;') + '</div>';
}
if (tags && tags.length) {
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'&lt;') + '</li>'; }).join('') + '</ul></div>';
}
meta.innerHTML = html;
meta.style.display = '';
} else {
meta.style.display = 'none';
meta.innerHTML = '';
}
positionCard(e);
});
el.addEventListener('mousemove', positionCard);
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
});
}
attachCardHover();
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script src="/static/app.js?v=20250826-4"></script>
{% if enable_themes %}
<script>
(function(){
try{
var sel = document.getElementById('theme-select');
var resetBtn = document.getElementById('theme-reset');
var root = document.documentElement;
var KEY = 'mtg:theme';
var SERVER_DEFAULT = '{{ default_theme }}';
function mapLight(v){ return v === 'light' ? 'light-blend' : v; }
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function normalizeUiValue(v){
var x = (v||'system').toLowerCase();
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
return x;
}
function apply(val){
var v = (val || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
v = mapLight(v);
root.setAttribute('data-theme', v);
}
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
var params = new URLSearchParams(window.location.search || '');
var urlTheme = (params.get('theme') || '').toLowerCase();
if (urlTheme) {
// Persist the UI value, not the mapped CSS token
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
// Clean the URL so reloads don't keep overriding
try { var u = new URL(window.location.href); u.searchParams.delete('theme'); window.history.replaceState({}, document.title, u.toString()); } catch(_){ }
}
// Determine initial selection: URL -> localStorage -> server default -> system
var stored = localStorage.getItem(KEY);
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
apply(initial);
if (sel){
sel.value = normalizeUiValue(initial);
sel.addEventListener('change', function(){
var v = sel.value || 'system';
localStorage.setItem(KEY, v);
apply(v);
});
}
if (resetBtn){
resetBtn.addEventListener('click', function(){
try{ localStorage.removeItem(KEY); }catch(_){ }
var v = SERVER_DEFAULT || 'system';
apply(v);
if (sel) sel.value = normalizeUiValue(v);
});
}
// React to system changes when set to system
if (window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){
var cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
if (cur === 'system') apply('system');
});
}
}catch(_){ }
})();
</script>
{% endif %}
{% if not enable_themes %}
<script>
(function(){
try{
// Apply THEME env even when the selector is disabled. Resolve 'system' to OS preference.
var root = document.documentElement;
var SERVER_DEFAULT = '{{ default_theme }}';
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
var v = (SERVER_DEFAULT || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
if (v === 'light') v = 'light-blend';
root.setAttribute('data-theme', v);
// Track OS changes when using system
if ((SERVER_DEFAULT||'system').toLowerCase() === 'system' && window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){ root.setAttribute('data-theme', resolveSystem()); });
}
}catch(_){ }
})();
</script>
{% endif %}
{% if enable_pwa %}
<script>
(function(){
try{
if ('serviceWorker' in navigator){
navigator.serviceWorker.register('/static/sw.js').then(function(reg){
window.__pwaStatus = { registered: true, scope: reg.scope };
}).catch(function(){ window.__pwaStatus = { registered: false }; });
}
}catch(_){ }
})();
</script>
{% endif %}
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){
try{
var raw = sessionStorage.getItem('mtg:toastAfterReload');
if (raw){
sessionStorage.removeItem('mtg:toastAfterReload');
var data = JSON.parse(raw);
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
}
}catch(_){ }
})();
</script>
</body>
</html>

View file

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

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

@ -0,0 +1,25 @@
{# Build Stage Navigator: shows steps and allows jumping via HTMX #}
{% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %}
{% set index = step_index if step_index is defined else i if i is defined else 1 %}
{% set total = step_total if step_total is defined else n if n is defined else 5 %}
<nav class="stage-nav" aria-label="Build stages">
<ol>
{% for idx in range(1, total+1) %}
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}
{% set is_cur = (idx == index) %}
{% set is_done = (idx < index) %}
<li class="stage-item{% if is_cur %} current{% endif %}{% if is_done %} done{% endif %}">
<button
class="stage-link"
{% if is_cur %}aria-current="step"{% endif %}
hx-get="/build/step{{ idx }}"
hx-target="#wizard"
hx-swap="innerHTML"
title="Go to {{ name }}">
<span class="idx">{{ idx }}</span>
<span class="name">{{ name }}</span>
</button>
</li>
{% endfor %}
</ol>
</nav>

View file

@ -0,0 +1,396 @@
<section>
{% set step_index = 1 %}{% set step_total = 5 %}
<h3>Step 1: Choose a Commander</h3>
{% include "build/_stage_navigator.html" %}
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
<label for="cmdr-search">Search by name</label>
<span class="input-wrap">
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" aria-describedby="cmdr-help" aria-controls="candidate-grid" placeholder="Type a commander name…" />
<button id="cmdr-clear" type="button" class="clear-btn" title="Clear search" aria-label="Clear search" hidden>×</button>
</span>
<input id="active-name" type="hidden" name="active" value="{{ active or '' }}" />
<button type="submit">Search</button>
<label style="margin-left:.5rem; font-weight:normal;">
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
</label>
<span id="search-spinner" class="spinner" aria-hidden="true" hidden style="display:none;"></span>
</form>
<div id="cmdr-help" class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
Tip: Press Enter to select the highlighted result, or use arrow keys to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
</div>
<div id="selection-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="results-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="kbd-hint" class="hint" hidden>
<span class="hint-text">Use
<span class="keys"><kbd></kbd><kbd></kbd></span> to navigate, <kbd>Enter</kbd> to select
</span>
<button type="button" class="hint-close" title="Dismiss keyboard hint" aria-label="Dismiss">×</button>
</div>
{% if candidates %}
<h4 style="display:flex; align-items:center; gap:.5rem;">
Top matches
<small class="muted" aria-live="polite">{% if count is defined %}{{ count }} result{% if count != 1 %}s{% endif %}{% else %}{{ (candidates|length) if candidates else 0 }} results{% endif %}</small>
</h4>
<div class="candidate-grid" id="candidate-grid" role="list">
{% for name, score, colors in candidates %}
<div class="candidate-tile{% if active and active == name %} active{% endif %}" data-card-name="{{ name }}" role="listitem" aria-selected="{% if active and active == name %}true{% else %}false{% endif %}">
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ name }}" />
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
alt="{{ name }}" loading="lazy" decoding="async" />
</button>
</form>
<div class="meta">
<div class="name"><span class="name-text">{{ name }}</span></div>
<div class="score">
match {{ score }}%
{% if colors %}
<span class="colors" style="margin-left:.25rem;">
{% for c in colors %}
<span class="chip chip-{{ c|lower }}" title="{{ c }}">{{ c }}</span>
{% endfor %}
</span>
{% endif %}
</div>
<form hx-post="/build/step1/inspect" hx-target="#wizard" hx-swap="innerHTML" style="margin-top:.25rem;">
<input type="hidden" name="name" value="{{ name }}" />
<button type="submit">Inspect</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
<div id="candidate-grid" class="muted" style="margin-top:.5rem;" aria-live="polite">
No results for “{{ query }}”. Try a shorter name or a different spelling.
</div>
{% endif %}
{% if inspect and inspect.ok %}
<div class="two-col two-col-left-rail">
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ selected }}" />
</a>
</aside>
<div class="grow">
<h4>Theme Tags</h4>
{% if tags and tags|length > 0 %}
<ul>
{% for t in tags %}
<li>{{ t }}</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No theme tags found for this commander.</p>
{% endif %}
<div style="margin-top:.75rem;">
<form style="display:inline" hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ selected }}" />
<button class="btn-continue" data-action="continue">Use this commander</button>
</form>
<form style="display:inline" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="query" value="" />
<button class="btn-back" data-action="back">Back to search</button>
</form>
<form action="/build" method="get" style="display:inline; margin-left:.5rem;">
<button type="submit">Start over</button>
</form>
</div>
<div hx-get="/build/banner?step=Choose%20Commander&i=1&n=5" hx-trigger="load"></div>
</div>
</div>
{% elif inspect and not inspect.ok %}
<div style="color:#a00">{{ inspect.error }}</div>
{% endif %}
{% if error %}
<div style="color:#a00">{{ error }}</div>
{% endif %}
</section>
<script>
(function(){
var input = document.getElementById('cmdr-search');
var form = document.getElementById('cmdr-search-form');
var grid = document.getElementById('candidate-grid');
var spinner = document.getElementById('search-spinner');
var activeField = document.getElementById('active-name');
var selLive = document.getElementById('selection-live');
var resultsLive = document.getElementById('results-live');
var hint = document.getElementById('kbd-hint');
var defaultPlaceholder = (input && input.placeholder) ? input.placeholder : 'Type a commander name…';
var clearBtn = document.getElementById('cmdr-clear');
var initialDescribedBy = (input && input.getAttribute('aria-describedby')) || '';
// Persist auto-select preference
try {
var autoCb = document.querySelector('input[name="auto"][type="checkbox"]');
if (autoCb) {
var saved = localStorage.getItem('step1-auto');
if (saved === '1' || saved === '0') autoCb.checked = (saved === '1');
autoCb.addEventListener('change', function(){ localStorage.setItem('step1-auto', autoCb.checked ? '1' : '0'); });
}
} catch(_){ }
if (!input || !form) return;
// Show keyboard hint only when candidates exist and user hasn't dismissed it
function showHintIfNeeded() {
try {
if (!hint) return;
var dismissed = localStorage.getItem('step1-hint-dismissed') === '1';
var hasTiles = !!(document.getElementById('candidate-grid') && document.getElementById('candidate-grid').querySelector('.candidate-tile'));
var shouldShow = !(dismissed || !hasTiles);
hint.hidden = !shouldShow;
// Link hint to input a11y description only when visible
if (input) {
var base = initialDescribedBy.trim();
var parts = base ? base.split(/\s+/) : [];
var idx = parts.indexOf('kbd-hint');
if (shouldShow) {
if (idx === -1) parts.push('kbd-hint');
} else {
if (idx !== -1) parts.splice(idx, 1);
}
if (parts.length) input.setAttribute('aria-describedby', parts.join(' '));
else input.removeAttribute('aria-describedby');
}
} catch(_) { /* noop */ }
}
showHintIfNeeded();
// Close button for hint
try {
var closeBtn = hint ? hint.querySelector('.hint-close') : null;
if (closeBtn) {
closeBtn.addEventListener('click', function(){
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
});
}
} catch(_){ }
// Debounce live search
var t = null;
function submit(){
if (!form) return;
// Trigger the HTMX post without clicking submit
if (window.htmx) { window.htmx.trigger(form, 'submit'); }
else { form.submit(); }
}
input.addEventListener('input', function(){
if (t) clearTimeout(t);
t = setTimeout(submit, 250);
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
});
// Initialize clear visibility
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
if (clearBtn) clearBtn.addEventListener('click', function(){
if (!input) return;
input.value = '';
try { clearBtn.hidden = true; } catch(_){ }
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
try { input.focus(); } catch(_){}
});
// Focus the search box on load if nothing else is focused
try {
var ae = document.activeElement;
if (input && (!ae || ae === document.body)) { input.focus(); input.select && input.select(); }
} catch(_){}
// Quick focus: press "/" to focus the search input (unless already typing)
document.addEventListener('keydown', function(e){
if (e.key !== '/') return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (input) { input.focus(); try { input.select(); } catch(_){} }
});
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
document.addEventListener('keydown', function(e){
// Dismiss hint on first keyboard navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
}
if (!grid || !grid.children || grid.children.length === 0) return;
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
// Ensure something is selected by default
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
if (idx < 0 && tiles.length > 0) {
tiles[0].classList.add('active');
try { if (activeField) activeField.value = tiles[0].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[0].dataset.cardName || ''); } catch(_){}
idx = 0;
}
// Determine columns via first row's offsetTop count
var cols = 1;
if (tiles.length > 1) {
var firstTop = tiles[0].offsetTop;
cols = tiles.findIndex(function(el, i){ return i>0 && el.offsetTop !== firstTop; });
if (cols === -1 || cols === 0) cols = tiles.length; // single row fallback
}
function setActive(newIdx) {
// Clamp to bounds; wrapping handled by callers
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
tiles.forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tiles[newIdx].classList.add('active');
tiles[newIdx].setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tiles[newIdx].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[newIdx].dataset.cardName || ''); } catch(_){}
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return newIdx;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
var total = tiles.length;
var rows = Math.ceil(total / cols);
var row = Math.floor(idx / cols);
var col = idx % cols;
var newRow = row + 1;
if (newRow >= rows) newRow = 0; // wrap to first row
var newIdx = newRow * cols + col;
if (newIdx >= total) newIdx = total - 1; // clamp to last tile if last row shorter
idx = setActive(newIdx);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
var totalU = tiles.length;
var rowsU = Math.ceil(totalU / cols);
var rowU = Math.floor(idx / cols);
var colU = idx % cols;
var newRowU = rowU - 1;
if (newRowU < 0) newRowU = rowsU - 1; // wrap to last row
var newIdxU = newRowU * cols + colU;
if (newIdxU >= totalU) newIdxU = totalU - 1;
idx = setActive(newIdxU);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
var totalR = tiles.length;
idx = setActive((idx + 1) % totalR);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
var totalL = tiles.length;
idx = setActive((idx - 1 + totalL) % totalL);
} else if (e.key === 'Enter') {
e.preventDefault();
var active = tiles[idx];
if (!active) return;
var formSel = active.querySelector('form[hx-post="/build/step1/confirm"]');
if (formSel) {
if (window.htmx) { window.htmx.trigger(formSel, 'submit'); }
else if (formSel.submit) { formSel.submit(); }
else {
var btn = active.querySelector('button');
if (btn) btn.click();
}
}
} else if (e.key === 'Escape') {
// ESC clears the search field and triggers a refresh
if (input && input.value) {
input.value = '';
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
}
}
});
// Persist current active on click selection movement too
if (grid) {
grid.addEventListener('click', function(e){
// Dismiss hint on interaction
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
var tile = e.target.closest('.candidate-tile');
if (!tile) return;
grid.querySelectorAll('.candidate-tile').forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tile.classList.add('active');
tile.setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tile.dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tile.dataset.cardName || ''); } catch(_){}
});
}
// Highlight matched text
try {
var q = (input.value || '').trim().toLowerCase();
if (q && grid) {
grid.querySelectorAll('.name-text').forEach(function(el){
var txt = el.textContent || '';
var low = txt.toLowerCase();
var i = low.indexOf(q);
if (i >= 0) {
el.innerHTML = txt.substring(0, i) + '<mark>' + txt.substring(i, i+q.length) + '</mark>' + txt.substring(i+q.length);
}
});
}
} catch(_){}
// HTMX spinner binding for this form — only show if no results are currently displayed
if (window.htmx && form) {
form.addEventListener('htmx:beforeRequest', function(){
var hasTiles = false;
try { hasTiles = !!(grid && grid.querySelector('.candidate-tile')); } catch(_){}
if (spinner) spinner.hidden = hasTiles ? true : false;
if (!hasTiles && input) input.placeholder = 'Searching…';
try { form.setAttribute('aria-busy', 'true'); } catch(_){ }
if (resultsLive) resultsLive.textContent = 'Searching…';
});
form.addEventListener('htmx:afterSwap', function(){
if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder;
// After swap, if there are no candidate tiles, clear active selection and live text
try {
var grid2 = document.getElementById('candidate-grid');
var hasAny = !!(grid2 && grid2.querySelector('.candidate-tile'));
if (!hasAny) {
if (activeField) activeField.value = '';
if (selLive) selLive.textContent = '';
}
// Re-evaluate hint visibility post-swap
showHintIfNeeded();
// Announce results count
try {
var qNow = (input && input.value) ? input.value.trim() : '';
var cnt = 0;
if (grid2) cnt = grid2.querySelectorAll('.candidate-tile').length;
if (resultsLive) {
if (cnt > 0) resultsLive.textContent = cnt + (cnt === 1 ? ' result' : ' results');
else if (qNow) resultsLive.textContent = 'No results for "' + qNow + '"';
else resultsLive.textContent = '';
}
} catch(_){ }
try { form.removeAttribute('aria-busy'); } catch(_){ }
} catch(_){ }
});
form.addEventListener('htmx:responseError', function(){ if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder; });
}
})();
</script>
<style>
.candidate-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:.75rem; }
.candidate-tile { border:1px solid var(--border); border-radius:8px; background:#0f1115; padding:.5rem; }
.candidate-tile.active { outline:2px solid #3b82f6; }
.img-btn { display:block; width:100%; background:transparent; border:0; padding:0; cursor:pointer; }
.img-btn img { width:100%; height:auto; border-radius:6px; }
.chip { display:inline-block; padding:0 .35rem; border-radius:999px; font-size:.75rem; line-height:1.4; border:1px solid var(--border); background:#151821; margin-left:.15rem; }
.chip-w { background:#fdf4d6; color:#6b4f00; border-color:#e9d8a6; }
.chip-u { background:#dbeafe; color:#1e40af; border-color:#93c5fd; }
.chip-b { background:#e5e7eb; color:#111827; border-color:#9ca3af; }
.chip-g { background:#dcfce7; color:#065f46; border-color:#86efac; }
.chip-r { background:#fee2e2; color:#991b1b; border-color:#fecaca; }
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
.candidate-tile { cursor: pointer; }
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.spinner { display:inline-block; width:16px; height:16px; border:2px solid #93c5fd; border-top-color: transparent; border-radius:50%; animation: spin 0.8s linear infinite; vertical-align:middle; margin-left:.4rem; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Ensure hidden attribute always hides spinner within this fragment */
.spinner[hidden] { display: none !important; }
.hint { display:flex; align-items:center; gap:.5rem; background:#0b1220; border:1px solid var(--border); color:#cbd5e1; padding:.4rem .6rem; border-radius:8px; margin:.4rem 0 .6rem; }
.hint .hint-close { background:transparent; border:0; color:#9aa4b2; font-size:1rem; line-height:1; cursor:pointer; }
.hint .keys kbd { background:#1f2937; color:#e5e7eb; padding:.1rem .3rem; border-radius:4px; margin:0 .1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:.85em; }
.input-wrap { position: relative; display:inline-flex; align-items:center; }
.clear-btn { position:absolute; right:.35rem; background:transparent; color:#9aa4b2; border:0; cursor:pointer; font-size:1.1rem; line-height:1; padding:.1rem .2rem; }
.clear-btn:hover { color:#cbd5e1; }
</style>

View file

@ -0,0 +1,320 @@
<section>
{# 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">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander.name }}" />
</a>
</aside>
<div class="grow" data-skeleton>
<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 }}" />
{% if error %}
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
{% endif %}
<fieldset>
<legend>Theme Tags</legend>
{% if tags %}
<input type="hidden" name="primary_tag" id="primary_tag" value="{{ primary_tag or '' }}" />
<input type="hidden" name="secondary_tag" id="secondary_tag" value="{{ secondary_tag or '' }}" />
<input type="hidden" name="tertiary_tag" id="tertiary_tag" value="{{ tertiary_tag or '' }}" />
<input type="hidden" name="tag_mode" id="tag_mode" value="{{ tag_mode or 'AND' }}" />
<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" aria-describedby="combine-help-tip">
<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" {% if (tag_mode or 'AND') == 'AND' %}checked{% endif %} /> AND
</label>
<label title="OR treats your themes as a union (broader pool, fills easier).">
<input type="radio" name="combine_mode_radio" value="OR" {% if tag_mode == 'OR' %}checked{% endif %} /> OR
</label>
</div>
<button type="button" id="reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<span id="tag-count" class="muted" style="font-size:12px;"></span>
</div>
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></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>
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
</div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
<ul style="margin:.25rem 0; padding-left:1.1rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
{% endfor %}
</ul>
</div>
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
{% 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{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
{% endfor %}
</div>
{% else %}
<p>No theme tags available for this commander.</p>
{% endif %}
</fieldset>
<fieldset>
<legend>Budget/Power Bracket</legend>
<div style="display:grid; gap:.5rem;">
{% for b in brackets %}
<label style="display:flex; gap:.5rem; align-items:flex-start;">
<input type="radio" name="bracket" value="{{ b.level }}" {% if (selected_bracket is defined and selected_bracket == b.level) or (selected_bracket is not defined and loop.first) %}checked{% endif %} />
<span><strong>{{ b.name }}</strong><small>{{ b.desc }}</small></span>
</label>
{% endfor %}
</div>
<div class="muted" style="margin-top:.35rem; font-size:.9em;">
Note: This guides deck creation and relaxes/raises constraints, but it is not a guarantee the final deck strictly fits that bracket.
</div>
</fieldset>
<div style="margin-top:1rem;">
<button type="submit" class="btn-continue" data-action="continue">Continue to Ideals</button>
</div>
</form>
<div style="margin-top:.5rem;">
<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>
</div>
</div>
</section>
<script>
(function(){
var chipHost = document.getElementById('tag-chip-list');
var recoHost = document.getElementById('tag-reco-list');
var selAll = document.getElementById('reco-select-all');
var resetBtn = document.getElementById('reset-tags');
var primary = document.getElementById('primary_tag');
var secondary = document.getElementById('secondary_tag');
var tertiary = document.getElementById('tertiary_tag');
var tagMode = document.getElementById('tag_mode');
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; }
function getSelected(){
var arr = [];
if (primary && primary.value) arr.push(primary.value);
if (secondary && secondary.value) arr.push(secondary.value);
if (tertiary && tertiary.value) arr.push(tertiary.value);
return arr;
}
function setSelected(arr){
arr = Array.from(new Set(arr || [])).filter(Boolean).slice(0,3);
if (primary) primary.value = arr[0] || '';
if (secondary) secondary.value = arr[1] || '';
if (tertiary) tertiary.value = arr[2] || '';
updateCount();
persist();
updateOrderUI();
}
function toggleTag(t){
var cur = getSelected();
var idx = cur.indexOf(t);
if (idx >= 0) { cur.splice(idx, 1); }
else {
if (cur.length >= 3) { cur = cur.slice(1); }
cur.push(t);
}
setSelected(cur);
updateChipsState();
}
function updateCount(){
try { if (countEl) countEl.textContent = getSelected().length + ' / 3 selected'; } catch(_){}
}
function persist(){
try {
localStorage.setItem(storageKey('tags'), JSON.stringify(getSelected()));
if (tagMode) localStorage.setItem(storageKey('mode'), tagMode.value || 'AND');
} catch(_){}
}
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); }
if (tagMode) { tagMode.value = (savedMode === 'OR' ? 'OR' : 'AND'); }
// sync radios
syncModeRadios();
} catch(_){}
}
function syncModeRadios(){
try {
var radios = document.querySelectorAll('input[name="combine_mode_radio"]');
Array.prototype.forEach.call(radios, function(r){ r.checked = (r.value === (tagMode && tagMode.value || 'AND')); });
} catch(_){}
}
function updateChipsState(){
var sel = getSelected();
function applyToContainer(container){
if (!container) return;
var chips = Array.prototype.slice.call(container.querySelectorAll('button.chip'));
chips.forEach(function(btn){
var t = btn.dataset.tag || '';
var active = sel.indexOf(t) >= 0;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
// update numeric badge for order
var old = btn.querySelector('sup.tag-order');
if (old) { try { old.remove(); } catch(_){} }
if (active){
var idx = sel.indexOf(t);
if (idx >= 0){
var sup = document.createElement('sup');
sup.className = 'tag-order';
sup.style.marginLeft = '.25rem';
sup.style.opacity = '.75';
sup.textContent = String(idx + 1);
btn.appendChild(sup);
}
}
});
}
applyToContainer(chipHost);
applyToContainer(recoHost);
updateCount();
updateOrderUI();
updateSelectAllState();
}
function updateOrderUI(){
if (!orderEl) return;
var sel = getSelected();
if (!sel.length){ orderEl.textContent = ''; return; }
try {
var parts = sel.map(function(t, i){ return (i+1) + '. ' + t; });
orderEl.textContent = 'Selected order: ' + parts.join(' • ');
} catch(_){ orderEl.textContent = ''; }
}
// bind mode radios
Array.prototype.forEach.call(document.querySelectorAll('input[name="combine_mode_radio"]'), function(r){
r.addEventListener('change', function(){ if (tagMode) { tagMode.value = r.value; persist(); } });
});
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
// attach handlers to existing chips
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
btn.addEventListener('keydown', function(e){
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
var ix = chips.indexOf(e.currentTarget);
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
if (next) { try { next.focus(); } catch(_){ } }
}
});
});
// attach handlers to recommended chips and select-all
if (recoHost){
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
});
if (selAll){
selAll.addEventListener('click', function(){
try {
var sel = getSelected();
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
var combined = sel.slice();
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
combined = combined.slice(-3); // keep last 3
setSelected(combined);
updateChipsState();
updateSelectAllState();
} catch(_){ }
});
}
// Why recommended panel toggle
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
function setWhy(open){
if (!whyBtn || !whyPanel) return;
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
whyPanel.style.display = open ? 'block' : 'none';
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (whyBtn && whyPanel){
whyBtn.addEventListener('click', function(e){
e.stopPropagation();
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
setWhy(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
});
document.addEventListener('click', function(e){
try {
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
setWhy(false);
} catch(_){}
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ setWhy(false); }
});
}
}
function updateSelectAllState(){
try {
if (!selAll) return;
var sel = getSelected();
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
var atCap = sel.length >= 3;
var noNew = unselected.length === 0;
var disable = atCap || noNew;
selAll.disabled = disable;
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
if (disable){
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
} else {
selAll.title = 'Add recommended up to 3';
}
} catch(_){ }
}
// initial: set from template-selected values, then maybe load persisted if none
updateChipsState();
loadPersisted();
updateChipsState();
})();
</script>

View file

@ -0,0 +1,44 @@
<section>
{# 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">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
{% if error %}
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
{% endif %}
<form hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML">
<fieldset>
<legend>Card Type Targets</legend>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem;">
{% for key, label in labels.items() %}
<label>
{{ label }}
<input type="number" name="{{ key }}" min="0" value="{{ (values or defaults)[key] }}" />
<small class="muted">Default: {{ defaults[key] }}</small>
</label>
{% endfor %}
</div>
</fieldset>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<button type="submit" class="btn-continue" data-action="continue">Continue to Review</button>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</div>
</form>
<div style="margin-top:.5rem;">
<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>
</div>
</div>
</section>

View file

@ -0,0 +1,45 @@
<section>
{# 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">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
</aside>
<div class="grow" data-skeleton>
<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() %}
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
{% endfor %}
</ul>
<form hx-post="/build/toggle-owned-review" hx-target="#wizard" hx-swap="innerHTML" style="margin:.5rem 0; display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="use_owned_only" value="1" {% if owned_only %}checked{% endif %} onchange="this.form.requestSubmit();" />
Use only owned cards
</label>
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
Prefer owned cards (allow unowned fallback)
</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 hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,365 @@
<section>
{# 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 }}" 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;">
{% if csv_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ csv_path }}" />
<button type="submit">Download CSV</button>
</form>
{% endif %}
{% if txt_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ txt_path }}" />
<button type="submit">Download TXT</button>
</form>
{% endif %}
</div>
{% endif %}
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
<div style="display:flex;align-items:center;gap:1rem;">
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
</div>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
</div>
<p>Bracket: {{ bracket }}</p>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
{% if i and n %}
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
{% endif %}
{% set deck_count = (total_cards if total_cards is not none else 0) %}
<span class="chip"><span class="dot" style="background: var(--green-main);"></span> Deck {{ deck_count }}/100</span>
{% 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) %}
{% set pct_int = pct_clamped|int %}
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %}" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" style="margin:.25rem 0 1rem 0;" data-pct="{{ pct_int }}">
<div class="bar"></div>
</div>
{% if status %}
<div style="margin-top:1rem;">
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
</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" />
<select name="filter_owned" data-pref="cards:owned">
<option value="all">All</option>
<option value="owned">Owned</option>
<option value="not">Not owned</option>
</select>
<label style="display:flex;align-items:center;gap:.35rem;">
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
</label>
<label style="display:flex;align-items:center;gap:.35rem;">
<input type="checkbox" name="collapse_groups" data-pref="cards:collapse" /> Collapse groups
</label>
<select name="filter_sort" data-pref="cards:sort" aria-label="Sort">
<option value="az">AZ</option>
<option value="owned">Owned first</option>
<option value="gc">Game-changers first</option>
</select>
<span class="sep"></span>
<span class="hint">Visible: <strong data-results>0</strong></span>
<span class="sep"></span>
<div class="chips-inline">
<span class="chip" data-chip-owned="all">All</span>
<span class="chip" data-chip-owned="owned">Owned</span>
<span class="chip" data-chip-owned="not">Not owned</span>
<span class="chip" data-chip-clear>Clear</span>
</div>
</div>
<!-- Sticky build controls on mobile -->
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</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' }}" />
<button type="submit" class="btn-continue" data-action="continue" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<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; }" />
Show skipped stages
</label>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</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>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Owned</span>
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Not owned</span>
</div>
{% 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() %}
{% else %}
{% set heading = 'Additional Picks' %}
{% endif %}
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
<div class="group-header">
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
<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 {% 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)) %}
{% 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; 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 {% 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)) %}
{% 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; 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>
{% endif %}
{% if show_logs and log %}
<details style="margin-top:1rem;">
<summary>Show logs</summary>
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
</details>
{% endif %}
<!-- controls now above -->
{% if status and status.startswith('Build complete') %}
{% if summary %}
{% include "partials/deck_summary.html" %}
{% endif %}
{% endif %}
</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

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% 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">
<!-- 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

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block content %}
<h2>Build from JSON</h2>
<div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;">
<p class="muted" style="max-width: 70ch; margin:0;">
Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.
</p>
<div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
</div>
<pre style="margin-top:.35rem; background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
</div>
</div>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if notice %}<div class="notice">{{ notice }}</div>{% endif %}
<div class="config-actions" style="margin-bottom:1rem; display:flex; gap:12px; align-items:center;">
<form hx-post="/configs/upload" hx-target="#config-list" hx-swap="outerHTML" enctype="multipart/form-data">
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload JSON</button>
<input id="upload-json" type="file" name="file" accept="application/json" style="display:none" onchange="this.form.requestSubmit();">
</form>
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:#0f1115; color:#e5e7eb;" />
</div>
<script>
(function(){
// Drag and drop upload support
var form = document.querySelector('.config-actions form[enctype="multipart/form-data"]');
var fileInput = document.getElementById('upload-json');
if (form && fileInput) {
form.addEventListener('dragover', function(e){ e.preventDefault(); form.style.outline = '2px dashed #334155'; });
form.addEventListener('dragleave', function(){ form.style.outline = ''; });
form.addEventListener('drop', function(e){
e.preventDefault(); form.style.outline = '';
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
form.requestSubmit();
}
});
}
// Client-side filter of config list
var filter = document.getElementById('config-filter');
var list = document.getElementById('config-list');
function applyFilter() {
if (!list) return;
var q = (filter.value || '').toLowerCase().trim();
var items = list.querySelectorAll('li');
items.forEach(function(li){
var txt = (li.textContent || '').toLowerCase();
li.style.display = (!q || txt.indexOf(q) !== -1) ? '' : 'none';
});
}
if (filter) {
filter.addEventListener('input', applyFilter);
}
document.addEventListener('htmx:afterSwap', function(e){ if (e && e.target && e.target.id === 'config-list') applyFilter(); });
})();
</script>
<div id="config-list">
{% if not items %}
<p>No configs found in /config. Export a run config from a build, or upload one here.</p>
{% else %}
<ul class="file-list" style="list-style: none; margin: 0; padding: 0;">
{% for it in items %}
<li>
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div style="display:flex; align-items:center; gap:10px;">
<strong>
{% if it.commander %}
<span data-card-name="{{ it.commander }}" data-tags="{{ (it.tags|join(', ')) if it.tags else '' }}">{{ it.commander }}</span>
{% else %}
{{ it.name }}
{% endif %}
</strong>
{% if it.tags %}<span style="color:#64748b;">[{{ ', '.join(it.tags) }}]</span>{% endif %}
{% if it.bracket_level is not none %}<span class="badge">Bracket {{ it.bracket_level }}</span>{% endif %}
</div>
<div class="actions" style="display:flex; gap:8px;">
<form method="get" action="/configs/view" style="display:inline;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit" class="btn">View</button>
</form>
<form method="post" action="/configs/run" style="display:inline;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit">Run</button>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block content %}
<h2>Build from JSON: {{ cfg_name }}</h2>
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
{% if commander %}
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}{% if use_owned_only %} · Owned-only{% endif %}</div>
{% endif %}
<div class="two-col two-col-left-rail">
<aside class="card-preview">
{% if commander %}
<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" width="320" data-card-name="{{ commander }}" />
</a>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if ok and csv_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ csv_path }}" />
<button type="submit">Download CSV</button>
</form>
{% endif %}
{% if ok and txt_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ txt_path }}" />
<button type="submit">Download TXT</button>
</form>
{% endif %}
<form method="get" action="/configs" style="display:inline; margin:0;">
<button type="submit">Back to Build from JSON</button>
</form>
</div>
</aside>
<div class="grow">
{% if not ok %}
<div class="error">Build failed: {{ error }}</div>
{% else %}
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
{% if summary %}
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% endif %}
{% endif %}
{% if show_logs %}
<details style="margin-top:1rem;">
<summary>Show logs</summary>
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
</details>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<h2>Run from Config: {{ cfg_name }}</h2>
{% if not ok %}
<div class="error">Build failed: {{ error }}</div>
{% else %}
<div class="notice">Build completed.</div>
<div style="margin:.5rem 0;">
{% if csv_path %}<a class="btn" href="/files?path={{ csv_path | urlencode }}">Download CSV</a>{% endif %}
{% if txt_path %}<a class="btn" href="/files?path={{ txt_path | urlencode }}">Download TXT</a>{% endif %}
</div>
{% if summary %}
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
{# Reuse the same inner sections as the build summary page by including its markup #}
{% set __summary = summary %}
{% set summary = __summary %}
{% include "build/_step5.html" ignore missing %}
{% else %}
<p class="muted">No summary data available.</p>
{% endif %}
{% endif %}
<p style="margin-top:1rem;"><a href="/configs">Back to Configs</a></p>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<h2>Build from JSON: {{ name }}</h2>
<p class="muted" style="max-width: 70ch;">Review the configuration details below, then run a non-interactive build to produce deck exports and a summary.</p>
<details open>
<summary>Overview</summary>
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
<div>Commander</div><div>{{ data.commander }}</div>
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
<div>Combine Mode</div><div>{{ (data.tag_mode or data.combine_mode or 'AND') | upper }}</div>
<div>Bracket</div><div>{{ data.bracket_level }}</div>
</div>
</details>
<details style="margin-top:1rem;" open>
<summary>Ideal Counts</summary>
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
</details>
<form method="post" action="/configs/run" style="margin-top:1rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
<input type="hidden" name="name" value="{{ name }}" />
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="use_owned_only" value="1" />
Use only owned cards
</label>
<button type="submit">Run Headless</button>
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
</form>
{% endblock %}

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

@ -0,0 +1,656 @@
{% extends "base.html" %}
{% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %}
<h2 id="decks-heading">Finished Decks</h2>
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" aria-controls="deck-list" />
<select id="deck-sort" aria-label="Sort decks">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name-asc">Commander AZ</option>
<option value="name-desc">Commander ZA</option>
</select>
<select id="deck-theme" aria-label="Theme">
<option value="">All Themes</option>
</select>
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" id="deck-txt-only" /> TXT only
</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>
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
</div>
{% if items %}
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
{% for it in items %}
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
<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>
{% endif %}
<div class="muted" style="font-size:12px;">
{% if it.mtime is defined %}
<span title="Modified">{{ it.mtime | int }}</span>
{% endif %}
</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>
</form>
{% if it.txt_path %}
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.txt_path }}" />
<button type="submit" title="Download TXT" aria-label="Download TXT for {{ it.commander }}">TXT</button>
</form>
{% endif %}
<form action="/decks/view" method="get" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
<div id="deck-empty" class="muted" style="display:none; margin-top:.5rem;">No decks match your filters.</div>
<!-- Help modal -->
<div id="deck-help-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deck-help-title" hidden>
<div class="modal-backdrop" id="deck-help-backdrop"></div>
<div class="modal-content" role="document">
<div class="modal-header">
<h3 id="deck-help-title" style="margin:0;">Keyboard and tips</h3>
<button type="button" id="deck-help-close" aria-label="Close help">×</button>
</div>
<div class="modal-body">
<ul style="margin:.25rem 0 0 1rem;">
<li><kbd>/</kbd> focuses the filter</li>
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
<li><kbd>R</kbd> resets all filters, sort, and theme</li>
<li>Use “TXT only” to show only decks that have a TXT export</li>
<li>Share copies a link with your current filters</li>
</ul>
</div>
</div>
</div>
{% else %}
<div class="muted">No exports yet. Run a build to create one.</div>
{% endif %}
<script>
(function(){
var input = document.getElementById('deck-filter');
var sortSel = document.getElementById('deck-sort');
var themeSel = document.getElementById('deck-theme');
var clearBtn = document.getElementById('deck-clear');
var list = document.getElementById('deck-list');
var countEl = document.getElementById('deck-count');
var shareBtn = document.getElementById('deck-share');
var resetAllBtn = document.getElementById('deck-reset-all');
var liveEl = document.getElementById('deck-live');
var emptyEl = document.getElementById('deck-empty');
var helpBtn = document.getElementById('deck-help');
var helpModal = document.getElementById('deck-help-modal');
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 || '';
raw.split('|').forEach(function(t){ t = (t||'').trim(); if (t) themeSet.add(t); });
});
// Populate theme dropdown
if (themeSel) {
// Preserve current selection if any
var prev = themeSel.value || '';
// Reset to default option
themeSel.innerHTML = '<option value="">All Themes</option>';
Array.from(themeSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
var opt = document.createElement('option');
opt.value = t; opt.textContent = t; themeSel.appendChild(opt);
});
if (prev) {
// Re-apply previous selection if it exists
var has = Array.prototype.some.call(themeSel.options, function(o){ return o.value === prev; });
if (has) themeSel.value = prev;
}
}
// URL hash <-> state sync helpers
function parseHash(){
try {
var h = (location.hash || '').replace(/^#/, '');
if (!h) return null;
var qp = new URLSearchParams(h);
var q = qp.get('q') || '';
var sort = qp.get('sort') || '';
var tag = qp.get('tag') || '';
var tagsStr = qp.get('tags') || '';
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
if (!tag && tags.length) { tag = tags[0]; }
var txt = qp.get('txt');
var txtOnly = (txt === '1' || txt === 'true');
return { q: q, sort: sort, tag: tag, txt: txtOnly };
} catch(_) { return null; }
}
function updateHashFromState(){
try {
var q = (input && input.value) ? input.value.trim() : '';
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
var tag = (themeSel && themeSel.value) ? themeSel.value : '';
var qp = new URLSearchParams();
if (q) qp.set('q', q);
if (sort && sort !== 'newest') qp.set('sort', sort);
if (tag) qp.set('tag', tag);
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
var newHash = qp.toString();
var base = location.pathname + location.search;
var current = (location.hash || '').replace(/^#/, '');
if (current !== newHash) {
history.replaceState(null, '', base + (newHash ? ('#' + newHash) : ''));
}
} catch(_){ }
}
function applyStateFromHash(){
var s = parseHash();
if (!s) return false;
var changed = false;
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
if (typeof s.tag === 'string' && themeSel) {
// If the tag isn't present in options, add it for back-compat
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === s.tag; });
if (s.tag && !exists) { var opt = document.createElement('option'); opt.value = s.tag; opt.textContent = s.tag; themeSel.appendChild(opt); }
themeSel.value = s.tag; changed = true;
}
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
applyAll();
return changed;
}
function updateCount(){
if (!countEl) return;
var total = panels.length;
var visible = panels.filter(function(p){ return p.style.display !== 'none'; }).length;
countEl.textContent = visible + ' of ' + total + ' decks';
if (emptyEl) emptyEl.style.display = (visible === 0 ? '' : 'none');
try {
if (liveEl) {
if (visible === 0) liveEl.textContent = 'No decks match your filters';
else liveEl.textContent = 'Showing ' + visible + ' of ' + total + ' decks';
}
} catch(_){ }
return { total: total, visible: visible };
}
function applyFilter(){
var q = (input && input.value || '').toLowerCase();
var selTag = (themeSel && themeSel.value) ? themeSel.value : '';
panels.forEach(function(row){
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
var textMatch = hay.indexOf(q) >= 0;
var tagsPipe = row.dataset.tagsPipe || '';
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
var tagMatch = selTag ? (tags.indexOf(selTag) !== -1) : true;
var txtOk = true;
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
});
}
function highlightMatches(){
var q = (input && input.value || '').trim();
var ql = q.toLowerCase();
panels.forEach(function(row){
var strong = row.querySelector('strong[data-card-name]');
if (!strong) return;
var raw = strong.getAttribute('data-card-name') || strong.textContent || '';
if (!q) { strong.textContent = raw; return; }
var low = raw.toLowerCase();
var i = low.indexOf(ql);
if (i >= 0) {
strong.innerHTML = raw.substring(0, i) + '<mark>' + raw.substring(i, i+q.length) + '</mark>' + raw.substring(i+q.length);
} else {
strong.textContent = raw;
}
// Also highlight in Themes: ... line if present
try {
var themeEl = Array.prototype.slice.call(row.querySelectorAll('.muted')).find(function(el){
var t = (el.textContent || '').trim().toLowerCase();
return t.startsWith('themes:');
});
if (themeEl) {
if (!themeEl.dataset.raw) { themeEl.dataset.raw = themeEl.textContent || ''; }
var base = themeEl.dataset.raw;
if (!q) { themeEl.textContent = base; }
else {
var prefix = 'Themes: ';
var rest = base.startsWith(prefix) ? base.substring(prefix.length) : base;
var li = rest.toLowerCase().indexOf(ql);
if (li >= 0) {
themeEl.innerHTML = prefix + rest.substring(0, li) + '<mark>' + rest.substring(li, li+q.length) + '</mark>' + rest.substring(li+q.length);
} else {
themeEl.textContent = base;
}
}
}
} catch(_){ }
});
}
function applySort(){
var mode = (sortSel && sortSel.value) || 'newest';
var rows = panels.slice();
rows.sort(function(a,b){
if (mode === 'newest' || mode === 'oldest'){
var am = parseFloat(a.dataset.mtime || '0');
var bm = parseFloat(b.dataset.mtime || '0');
return (mode === 'newest') ? (bm - am) : (am - bm);
} else if (mode === 'name-asc' || mode === 'name-desc'){
var ac = (a.dataset.commander || '').toLowerCase();
var bc = (b.dataset.commander || '').toLowerCase();
var cmp = ac.localeCompare(bc);
return (mode === 'name-asc') ? cmp : -cmp;
}
return 0;
});
// Re-append in new order
rows.forEach(function(r){ list.appendChild(r); });
refreshPanels();
}
function applyAll(){
applyFilter();
applySort();
highlightMatches();
var counts = updateCount();
// If focus is on a hidden panel, move to first visible
try {
var active = document.activeElement;
if (active && list.contains(active)) {
var p = active.closest('.panel');
if (p && p.style.display === 'none') {
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
if (firstVis) firstVis.focus();
}
}
} catch(_){ }
// Persist state
try {
if (input) localStorage.setItem('decks-filter', input.value || '');
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
if (themeSel) localStorage.setItem('decks-theme', themeSel.value || '');
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
} catch(_){ }
// Update URL hash for shareable state
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;
return function(){
var ctx = this, args = arguments;
if (timer) clearTimeout(timer);
timer = setTimeout(function(){ fn.apply(ctx, args); }, delay);
};
}
var debouncedApply = debounce(applyAll, 150);
if (input) input.addEventListener('input', debouncedApply);
if (sortSel) sortSel.addEventListener('change', applyAll);
if (themeSel) themeSel.addEventListener('change', applyAll);
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
if (clearBtn) clearBtn.addEventListener('click', function(){
if (input) input.value = '';
if (themeSel) themeSel.value = '';
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
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 {
if (input) input.value = '';
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
if (themeSel) themeSel.value = '';
// Clear persistence
localStorage.removeItem('decks-filter');
localStorage.removeItem('decks-sort');
localStorage.removeItem('decks-theme');
localStorage.removeItem('decks-txt');
// Clear URL hash
var base = location.pathname + location.search;
history.replaceState(null, '', base);
} catch(_){ }
applyAll();
if (liveEl) liveEl.textContent = 'Filters, sort, and theme reset';
});
if (shareBtn) shareBtn.addEventListener('click', function(){
try {
// Ensure hash reflects current UI state
updateHashFromState();
var url = window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url);
} else {
var t = document.createElement('input');
t.value = url; document.body.appendChild(t); t.select(); try { document.execCommand('copy'); } catch(_){} document.body.removeChild(t);
}
var prev = shareBtn.textContent;
shareBtn.textContent = 'Copied';
setTimeout(function(){ shareBtn.textContent = prev; }, 1200);
if (liveEl) liveEl.textContent = 'Link copied to clipboard';
} catch(_){ }
});
// Initial state: prefer URL hash, fall back to localStorage
var hadHash = false;
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
if (hadHash) {
if (!applyStateFromHash()) { applyAll(); }
} else {
// Load persisted state
try {
var savedFilter = localStorage.getItem('decks-filter') || '';
if (input) input.value = savedFilter;
var savedSort = localStorage.getItem('decks-sort') || 'newest';
if (sortSel) sortSel.value = savedSort;
var savedTheme = localStorage.getItem('decks-theme') || '';
if (themeSel && savedTheme) {
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === savedTheme; });
if (!exists) { var opt = document.createElement('option'); opt.value = savedTheme; opt.textContent = savedTheme; themeSel.appendChild(opt); }
themeSel.value = savedTheme;
}
// Back-compat: if no savedTheme, try first of old saved tags
if (themeSel && !savedTheme) {
try {
var oldTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
if (Array.isArray(oldTags) && oldTags.length > 0) {
var ot = oldTags[0];
var ex2 = Array.prototype.some.call(themeSel.options, function(o){ return o.value === ot; });
if (!ex2) { var o2 = document.createElement('option'); o2.value = ot; o2.textContent = ot; themeSel.appendChild(o2); }
themeSel.value = ot;
}
} catch(_e){}
}
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
} catch(_){ }
applyAll();
}
// 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 {
var name = p.getAttribute('data-name') || '';
if (name) return '/decks/view?name=' + encodeURIComponent(name);
var form = p.querySelector('form[action="/decks/view"]');
if (form) {
var nameInput = form.querySelector('input[name="name"]');
if (nameInput && nameInput.value) return '/decks/view?name=' + encodeURIComponent(nameInput.value);
}
} catch(_){ }
return '/decks/view';
}
function openPanel(p, newTab){
if (!p) return;
if (newTab) { window.open(getPanelUrl(p), '_blank'); return; }
var openForm = p.querySelector('form[action="/decks/view"]');
if (openForm) {
if (window.htmx) { window.htmx.trigger(openForm, 'submit'); }
else if (openForm.submit) { openForm.submit(); }
} else { window.location.href = getPanelUrl(p); }
}
list.addEventListener('dblclick', function(e){
var p = e.target.closest('.panel');
if (!p) return;
// Ignore when double-clicking interactive controls
if (e.target.closest('button, a, input, select, textarea, label, form')) return;
openPanel(p);
});
list.addEventListener('keydown', function(e){
var p = e.target.closest('.panel[tabindex]');
if (!p) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
var newTab = !!(e.ctrlKey || e.metaKey || e.shiftKey);
openPanel(p, newTab);
}
});
// Arrow key navigation between visible panels
document.addEventListener('keydown', function(e){
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
var active = document.activeElement;
if (!active || !list.contains(active)) return;
var vis = Array.prototype.slice.call(list.querySelectorAll('.panel')).filter(function(p){ return p.style.display !== 'none'; });
if (!vis.length) return;
var idx = vis.indexOf(active.closest('.panel'));
if (idx === -1) return;
e.preventDefault();
var target = null;
if (e.key === 'ArrowDown') target = vis[Math.min(idx + 1, vis.length - 1)];
else if (e.key === 'ArrowUp') target = vis[Math.max(idx - 1, 0)];
else if (e.key === 'Home') target = vis[0];
else if (e.key === 'End') target = vis[vis.length - 1];
if (target) { try { target.focus(); } catch(_){ } }
});
// ESC clears filter when focused in the filter input
if (input) {
input.addEventListener('keydown', function(e){
if (e.key === 'Escape' && input.value) {
input.value = '';
debouncedApply();
} else if (e.key === 'Enter') {
// Open first visible deck when pressing Enter in filter
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
if (firstVis) { e.preventDefault(); openPanel(firstVis, !!(e.ctrlKey||e.metaKey||e.shiftKey)); }
}
});
}
// Quick focus: '/' focuses filter when not typing elsewhere
document.addEventListener('keydown', function(e){
if (e.key !== '/') return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (input) { input.focus(); try { input.select(); } catch(_){} }
});
// Global shortcut: 'R' to reset all (when not typing)
document.addEventListener('keydown', function(e){
if ((e.key === 'r' || e.key === 'R') && !(e.ctrlKey || e.altKey || e.metaKey)) {
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (resetAllBtn) { e.preventDefault(); resetAllBtn.click(); }
}
});
// Help modal wiring
(function(){
if (!helpBtn || !helpModal) return;
var prevFocus = null;
function openHelp(){
prevFocus = document.activeElement;
helpModal.hidden = false;
try { document.body.dataset.prevOverflow = document.body.style.overflow || ''; document.body.style.overflow = 'hidden'; } catch(_){ }
var close = helpClose || helpModal.querySelector('button');
if (close) try { close.focus(); } catch(_){ }
}
function closeHelp(){
helpModal.hidden = true;
try { document.body.style.overflow = document.body.dataset.prevOverflow || ''; } catch(_){ }
if (prevFocus) try { prevFocus.focus(); } catch(_){ }
}
helpBtn.addEventListener('click', openHelp);
if (helpClose) helpClose.addEventListener('click', closeHelp);
if (helpBackdrop) helpBackdrop.addEventListener('click', closeHelp);
document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && !helpModal.hidden) { e.preventDefault(); closeHelp(); } });
document.addEventListener('keydown', function(e){
if ((e.key === '?' || (e.shiftKey && e.key === '/')) && !(e.ctrlKey||e.metaKey||e.altKey)){
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
e.preventDefault();
if (helpModal.hidden) openHelp(); else closeHelp();
}
});
})();
// Enhance mtime display to human-readable date
try {
panels.forEach(function(p){
var m = parseFloat(p.dataset.mtime || '0');
if (!m) return;
var el = p.querySelector('[title="Modified"]');
if (el) {
try { el.textContent = new Date(m * 1000).toLocaleString(); } catch(_){}
}
});
} catch(_){ }
// (copy button removed)
})();
</script>
<style>
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
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

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% 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>
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
<div>
{% if commander %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if csv_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ csv_path }}" />
<button type="submit">Download CSV</button>
</form>
{% endif %}
{% if txt_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ txt_path }}" />
<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>
</div>
</div>
<div>
{% if summary %}
{% if owned_set %}
{% set ns = namespace(owned=0, total=0) %}
{% set tb = summary.type_breakdown %}
{% if tb and tb.cards %}
{% for t, clist in tb.cards.items() %}
{% for c in clist %}
{% set cnt = c.count if c.count else 1 %}
{% set ns.total = ns.total + cnt %}
{% if c.name and (c.name|lower in owned_set) %}
{% set ns.owned = ns.owned + cnt %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
{% set not_owned = (ns.total - ns.owned) %}
{% set pct = ( (ns.owned * 100.0 / (ns.total or 1)) | round(1) ) %}
<div class="panel" style="margin-bottom:.75rem;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<div><strong>Ownership</strong></div>
<div class="muted">Owned: {{ ns.owned }} • Not owned: {{ not_owned }} • Total: {{ ns.total }} ({{ pct }}%)</div>
</div>
</div>
{% endif %}
{{ 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 %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,137 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Diagnostics</h2>
<p class="muted">Use these tools to verify error handling surfaces.</p>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
<div id="themeSummary" style="margin-top:.5rem"></div>
<div style="margin-top:.35rem">
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div>
</div>
<div class="card" style="background: var(--panel); 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>
{% if enable_pwa %}
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">PWA status</h3>
<div id="pwaStatus" class="muted">Checking…</div>
</div>
{% endif %}
<div class="card" style="background: var(--panel); 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">
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
</div>
</div>
{% if show_logs %}
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
{% endif %}
</section>
<script>
(function(){
var el = document.getElementById('sysSummary');
function render(data){
if (!el) return;
try {
var v = (data && data.version) || 'dev';
var up = (data && data.uptime_seconds) || 0;
var st = (data && data.server_time_utc) || '';
var flags = (data && data.flags) || {};
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
'<div><strong>Flags:</strong> SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') +', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') +', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') +'</div>';
} catch(_){ el.textContent = 'Unavailable'; }
}
function load(){
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();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');
var resetBtn = document.getElementById('diag-theme-reset');
function renderTheme(){
if (!tEl) return;
var key = 'mtg:theme';
var stored = localStorage.getItem(key);
var html = '';
var resolved = document.documentElement.getAttribute('data-theme') || '';
html += '<div><strong>Resolved theme:</strong> ' + resolved + '</div>';
html += '<div><strong>Preference:</strong> ' + (stored ? stored : '(none)') + '</div>';
tEl.innerHTML = html;
}
renderTheme();
if (resetBtn){
resetBtn.addEventListener('click', function(){
try{ localStorage.removeItem('mtg:theme'); }catch(_){ }
// Re-apply from server default via base script by simulating system apply
try{
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var v = prefersDark ? 'dark' : 'light-blend';
document.documentElement.setAttribute('data-theme', v);
}catch(_){ }
renderTheme();
});
}
}catch(_){ }
try{
var p = document.getElementById('pwaStatus');
if (p){
function renderPwa(){
try{
var st = window.__pwaStatus || {};
p.innerHTML = '<div><strong>Registered:</strong> '+ (st.registered? 'Yes':'No') +'</div>' + (st.scope? '<div><strong>Scope:</strong> '+ st.scope +'</div>' : '');
}catch(_){ p.textContent = 'Unavailable'; }
}
setTimeout(renderPwa, 500);
}
}catch(_){ }
// 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,85 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Logs</h2>
<form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;">
<label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label>
<label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label>
<label>Level
<select name="level" id="levelSel">
{% set _lvl = (level or 'all') %}
<option value="all" {% if _lvl=='all' %}selected{% endif %}>All</option>
<option value="error" {% if _lvl=='error' %}selected{% endif %}>Error</option>
<option value="warning" {% if _lvl=='warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if _lvl=='info' %}selected{% endif %}>Info</option>
<option value="debug" {% if _lvl=='debug' %}selected{% endif %}>Debug</option>
</select>
</label>
<button class="btn" type="submit">Refresh</button>
<button class="btn" type="button" id="copyLogsBtn" title="Copy visible logs">Copy</button>
<label style="margin-left:1rem; display:inline-flex; align-items:center; gap:.35rem">
<input type="checkbox" id="autoRefreshLogs" data-pref="logs:auto" /> Auto-refresh
</label>
<label style="display:inline-flex; align-items:center; gap:.35rem">
every <input type="number" id="autoRefreshInterval" value="3" min="1" max="30" style="width:60px"> s
</label>
</form>
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
<script>
(function(){
var pre = document.getElementById('logTail');
var autoCb = document.getElementById('autoRefreshLogs');
var intervalInput = document.getElementById('autoRefreshInterval');
// hydrate from saved pref
try { var saved = window.__mtgState && window.__mtgState.get('logs:auto', false); if (typeof saved === 'boolean') autoCb.checked = saved; } catch(_){ }
var params = new URLSearchParams(window.location.search);
var tailAttr = (pre && pre.getAttribute('data-tail')) || '200';
var qAttr = (pre && pre.getAttribute('data-q')) || '';
var levelAttr = (pre && pre.getAttribute('data-level')) || 'all';
var tail = parseInt(params.get('tail') || tailAttr, 10) || parseInt(tailAttr, 10) || 200;
var q = params.get('q') || qAttr;
var level = params.get('level') || levelAttr;
var timer = null;
function fetchLogs(){
try {
var url = '/status/logs?tail='+encodeURIComponent(String(tail));
if (q) url += '&q='+encodeURIComponent(q);
if (level && level !== 'all') url += '&level='+encodeURIComponent(level);
fetch(url, { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(function(data){ if (pre && data && data.lines){ pre.textContent = (data.lines||[]).join(''); pre.scrollTop = pre.scrollHeight; } });
} catch(e){}
}
function start(){
stop();
var sec = parseInt(intervalInput.value||'3', 10); if (isNaN(sec) || sec < 1) sec = 3; if (sec > 30) sec = 30;
timer = setInterval(fetchLogs, sec * 1000);
fetchLogs();
}
function stop(){ if (timer){ clearInterval(timer); timer = null; } }
autoCb.addEventListener('change', function(){
try { window.__mtgState && window.__mtgState.set('logs:auto', !!autoCb.checked); } catch(_){ }
if (autoCb.checked) start(); else stop();
});
intervalInput.addEventListener('change', function(){ if (autoCb.checked) start(); });
if (autoCb.checked) start();
var levelSel = document.getElementById('levelSel');
if (levelSel){ levelSel.addEventListener('change', function(){ if (autoCb.checked) fetchLogs(); }); }
// Copy button
var copyBtn = document.getElementById('copyLogsBtn');
function copyText(text){
try { navigator.clipboard.writeText(text); return true; } catch(_) {
try {
var ta = document.createElement('textarea'); ta.value = text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); var ok = document.execCommand('copy'); ta.remove(); return ok; } catch(__){ return false; }
}
}
if (copyBtn){
copyBtn.addEventListener('click', function(){
var ok = copyText(pre ? pre.textContent || '' : '');
if (ok){ if (window.toast) window.toast('Copied logs'); copyBtn.textContent = 'Copied'; setTimeout(function(){ copyBtn.textContent='Copy'; }, 1200); }
});
}
})();
</script>
</section>
{% 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

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Page not found</h2>
<p>The page you requested could not be found.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
<details>
<summary>Details</summary>
<pre>Status: {{ status }}
Path: {{ request.url.path }}</pre>
</details>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Error {{ status }}</h2>
<p>{{ detail }}</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
<details>
<summary>Details</summary>
<pre>Status: {{ status }}
Path: {{ request.url.path }}</pre>
</details>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Internal Server Error</h2>
<p>Something went wrong.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<section>
<div class="actions-grid">
<a class="action-button primary" href="/build">Build a Deck</a>
<a class="action-button" href="/configs">Run a JSON Config</a>
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
<a class="action-button" href="/owned">Owned Library</a>
<a class="action-button" href="/decks">Finished Decks</a>
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,397 @@
{% extends "base.html" %}
{% block content %}
<section>
<h3>Owned Cards Library</h3>
<p class="muted">Upload .txt or .csv lists. Well extract names and keep a de-duplicated library for the web UI.</p>
{% if error %}
<div class="error" style="margin:.5rem 0;">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="notice" style="margin:.5rem 0;">{{ notice }}</div>
{% endif %}
<form action="/owned/upload" method="post" enctype="multipart/form-data" style="margin:.5rem 0 1rem 0;">
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload TXT/CSV</button>
<input id="upload-owned" type="file" name="file" accept=".txt,.csv" style="display:none" onchange="this.form.requestSubmit();" />
</form>
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.75rem;">
<form action="/owned/clear" method="post" style="display:inline;">
<button type="submit" {% if count == 0 %}disabled{% endif %}>Clear Library</button>
</form>
<a href="/owned/export" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export TXT</a>
<a href="/owned/export.csv" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export CSV</a>
<span class="muted">{{ count }} unique name{{ '' if count == 1 else 's' }} <span id="shown-count" style="margin-left:.25rem;">{% if count %}• {{ count }} shown{% endif %}</span></span>
</div>
{% if names and names|length %}
<div id="bulk-bar" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
<label style="display:flex; align-items:center; gap:.4rem;">
<input type="checkbox" id="select-all" /> Select all shown
</label>
<button type="button" id="btn-remove-selected" class="btn" disabled>Remove selected</button>
<button type="button" id="btn-remove-visible" class="btn" disabled>Remove visible</button>
<button type="button" id="btn-export-visible" class="btn" disabled>Export visible TXT</button>
<button type="button" id="btn-export-visible-csv" class="btn" disabled>Export visible CSV</button>
</div>
{% endif %}
{% if names and names|length %}
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
<select id="sort-by" data-pref="owned:sort" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="name">Sort: A → Z</option>
<option value="type">Sort: Type</option>
<option value="color">Sort: Color</option>
<option value="tags">Sort: Tags</option>
<option value="recent">Sort: Recently added</option>
</select>
<select id="filter-type" data-pref="owned:type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Types</option>
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-tag" data-pref="owned:tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<option value="">All Themes</option>
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-color" data-pref="owned:color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Colors</option>
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
{% if color_combos and color_combos|length %}
<option value="" disabled>──────────</option>
{% for code, label in color_combos %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
{% endif %}
</select>
<input id="filter-text" data-pref="owned:q" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<button type="button" id="clear-filters">Clear</button>
</div>
<div id="active-chips" class="muted" style="display:flex; flex-wrap:wrap; gap:6px; font-size:12px; margin:.25rem 0 .5rem 0;"></div>
{% 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;" {% 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 []) %}
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
<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" 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">
{% for c in cols %}
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
{% endfor %}
</div>
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
{% endif %}
</div>
</label>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="muted">No names yet. Upload a file to get started.</p>
{% endif %}
</section>
<script>
(function(){
var grid = document.getElementById('owned-grid');
if (!grid) return;
var box = document.getElementById('owned-box');
var bulk = document.getElementById('bulk-bar');
var selAll = document.getElementById('select-all');
var btnRemoveSel = document.getElementById('btn-remove-selected');
var btnRemoveVis = document.getElementById('btn-remove-visible');
var btnExportVis = document.getElementById('btn-export-visible');
var btnExportVisCsv = document.getElementById('btn-export-visible-csv');
var fSort = document.getElementById('sort-by');
var fType = document.getElementById('filter-type');
var fTag = document.getElementById('filter-tag');
var fColor = document.getElementById('filter-color');
var fText = document.getElementById('filter-text');
var btnClear = document.getElementById('clear-filters');
var shownCount = document.getElementById('shown-count');
var chips = document.getElementById('active-chips');
// State helpers for URL hash and localStorage
var state = {
get: function(k, d){ try{ var v=localStorage.getItem('mtg:'+k); return v!==null?JSON.parse(v):d; }catch(e){ return d; } },
set: function(k, v){ try{ localStorage.setItem('mtg:'+k, JSON.stringify(v)); }catch(e){} },
inHash: function(obj){ try{ var p=new URLSearchParams((location.hash||'').replace(/^#/,'')); Object.keys(obj||{}).forEach(function(k){ p.set(k, obj[k]); }); location.hash=p.toString(); }catch(e){} },
readHash: function(){ try{ return new URLSearchParams((location.hash||'').replace(/^#/,'')); }catch(e){ return new URLSearchParams(); } }
};
// Helper: build Scryfall image URL with optional cache-busting
function buildImageUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Resize the container to fill the viewport height
function sizeBox(){
if (!box) return;
try {
var rect = box.getBoundingClientRect();
var margin = 16; // breathing room at bottom
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
var h = Math.max(240, Math.floor(vh - rect.top - margin));
box.style.height = h + 'px';
} catch(_){}
}
function debounce(fn, delay){ var t=null; return function(){ var a=arguments, c=this; if(t) clearTimeout(t); t=setTimeout(function(){ fn.apply(c,a); }, delay); }; }
var debouncedSize = debounce(sizeBox, 100);
sizeBox();
window.addEventListener('resize', debouncedSize);
function passType(li, val){ if (!val) return true; var t=(li.getAttribute('data-type')||'').toLowerCase(); return t.indexOf(val.toLowerCase())!==-1; }
function passTag(li, val){ if (!val) return true; var ts=(li.getAttribute('data-tags')||''); if (!ts) return false; var parts=ts.split('|'); return parts.some(function(x){return x.toLowerCase()===val.toLowerCase();}); }
function canonCode(raw){
var s = (raw||'').toUpperCase();
var order = ['W','U','B','R','G'];
var out = [];
for (var i=0;i<order.length;i++){
var ch = order[i];
if (s.indexOf(ch) !== -1) out.push(ch);
}
if (out.length === 0){
// Treat empty or explicit C as colorless
if (s.indexOf('C') !== -1 || s === '') return 'C';
return '';
}
return out.join('');
}
function passColor(li, val){
if (!val) return true;
var cs=(li.getAttribute('data-colors')||'');
var vcode = canonCode(val);
var ccode = canonCode(cs);
if (!vcode) return true; // if somehow invalid selection
return ccode === vcode;
}
function passText(li, val){ if (!val) return true; var txt=(li.textContent||'').toLowerCase(); return txt.indexOf(val.toLowerCase())!==-1; }
function updateShownCount(){
if (!shownCount) return;
var total = 0;
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display !== 'none') total++; });
shownCount.textContent = (total > 0 ? '• ' + total + ' shown' : '');
// Enable/disable bulk buttons
var anyVisible = total > 0;
[btnRemoveVis, btnExportVis, btnExportVisCsv].forEach(function(b){ if (b) b.disabled = !anyVisible; });
updateSelectedState();
}
function apply(){
var vt = fType ? fType.value : '';
var vtag = fTag ? fTag.value : '';
var vc = fColor ? fColor.value : '';
var vx = fText ? fText.value.trim() : '';
Array.prototype.forEach.call(grid.children, function(li){
var ok = passType(li, vt) && passTag(li, vtag) && passColor(li, vc) && passText(li, vx);
li.style.display = ok ? '' : 'none';
});
resort();
updateShownCount();
renderChips();
// Persist
if (fSort && fSort.hasAttribute('data-pref')) state.set(fSort.getAttribute('data-pref'), fSort.value);
if (fType && fType.hasAttribute('data-pref')) state.set(fType.getAttribute('data-pref'), fType.value);
if (fTag && fTag.hasAttribute('data-pref')) state.set(fTag.getAttribute('data-pref'), fTag.value);
if (fColor && fColor.hasAttribute('data-pref')) state.set(fColor.getAttribute('data-pref'), fColor.value);
if (fText && fText.hasAttribute('data-pref')) state.set(fText.getAttribute('data-pref'), fText.value);
// Update URL hash
try{ state.inHash({ o_sort: (fSort?fSort.value:''), o_type: vt, o_tag: vtag, o_color: vc, o_q: vx }); }catch(_){ }
}
function renderChips(){
if (!chips) return;
var items = [];
if (fType && fType.value) items.push({ k:'Type', v: fType.value, clear: function(){ fType.value=''; apply(); } });
if (fTag && fTag.value) items.push({ k:'Theme', v: fTag.value, clear: function(){ fTag.value=''; apply(); } });
if (fColor && fColor.value) items.push({ k:'Colors', v: fColor.value, clear: function(){ fColor.value=''; apply(); } });
if (fText && fText.value) items.push({ k:'Search', v: fText.value, clear: function(){ fText.value=''; apply(); } });
chips.innerHTML = '';
if (!items.length){ chips.style.display='none'; return; }
chips.style.display='flex';
items.forEach(function(it){
var span = document.createElement('span');
span.style.border = '1px solid var(--border)';
span.style.borderRadius = '16px';
span.style.padding = '2px 8px';
span.style.background = '#0f1115';
span.textContent = it.k+': '+it.v+' ×';
span.style.cursor = 'pointer';
span.title = 'Clear '+it.k;
span.addEventListener('click', function(){ it.clear(); });
chips.appendChild(span);
});
}
// Bulk user-tag add/remove controls removed by request; inline chip removal remains supported.
function resort(){
if (!fSort) return;
var mode = fSort.value || 'name';
var lis = Array.prototype.slice.call(grid.children);
// Only consider visible items, but keep hidden in place after visible ones to avoid DOM thrash
var visible = lis.filter(function(li){ return li.style.display !== 'none'; });
var hidden = lis.filter(function(li){ return li.style.display === 'none'; });
function byName(a,b){ return (a.textContent||'').toLowerCase().localeCompare((b.textContent||'').toLowerCase()); }
function byType(a,b){ return (a.getAttribute('data-type')||'').toLowerCase().localeCompare((b.getAttribute('data-type')||'').toLowerCase()); }
function byColor(a,b){ return (a.getAttribute('data-colors')||'').localeCompare((b.getAttribute('data-colors')||'')); }
function byTags(a,b){ var ac=(a.getAttribute('data-tags')||'').split('|').filter(Boolean).length; var bc=(b.getAttribute('data-tags')||'').split('|').filter(Boolean).length; return ac-bc || byName(a,b); }
function byRecent(a,b){ var ta=parseInt(a.getAttribute('data-added')||'0',10); var tb=parseInt(b.getAttribute('data-added')||'0',10); return (tb-ta) || byName(a,b); }
var cmp = byName;
if (mode === 'type') cmp = byType;
else if (mode === 'color') cmp = byColor;
else if (mode === 'tags') cmp = byTags;
else if (mode === 'recent') cmp = byRecent;
visible.sort(cmp);
// Re-append in new order
var frag = document.createDocumentFragment();
visible.forEach(function(li){ frag.appendChild(li); });
hidden.forEach(function(li){ frag.appendChild(li); });
grid.appendChild(frag);
}
function getVisibleNames(){
var out=[];
Array.prototype.forEach.call(grid.children, function(li){
if (li.style.display === 'none') return;
var span = li.querySelector('[data-card-name]');
if (span) out.push(span.getAttribute('data-card-name'));
});
return out;
}
function getSelectedNames(){
var out=[];
Array.prototype.forEach.call(grid.children, function(li){
var cb = li.querySelector('input.sel');
if (cb && cb.checked){ var span = li.querySelector('[data-card-name]'); if (span) out.push(span.getAttribute('data-card-name')); }
});
return out;
}
function updateSelectedState(){
if (!bulk) return;
var selected = getSelectedNames();
if (btnRemoveSel) btnRemoveSel.disabled = selected.length === 0;
if (selAll){
// Reflect if all visible are selected
var vis = getVisibleNames();
selAll.checked = (vis.length>0 && selected.length === vis.length);
selAll.indeterminate = (selected.length>0 && selected.length < vis.length);
}
// Toggle selected class for visual feedback
Array.prototype.forEach.call(grid.children, function(li){
var cb = li.querySelector('input.sel');
li.classList.toggle('is-selected', !!(cb && cb.checked));
});
}
if (selAll){
selAll.addEventListener('change', function(){
var on = !!selAll.checked;
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display==='none') return; var cb=li.querySelector('input.sel'); if (cb) cb.checked = on; });
updateSelectedState();
});
}
grid.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) updateSelectedState(); });
// Keyboard: allow Enter/Space on the row to toggle selection
grid.addEventListener('keydown', function(e){
if (!(e.key === 'Enter' || e.key === ' ')) return;
var row = e.target && e.target.closest && e.target.closest('label.owned-row');
if (!row) return;
e.preventDefault();
var cb = row.querySelector('input.sel');
if (cb){ cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles:true })); }
});
function postJSON(url, body){ return fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{}) }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); }); }
function formPost(url, names){
var fd = new FormData(); fd.append('names', names.join(','));
return fetch(url, { method:'POST', body: fd }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); });
}
function confirmRemove(count){
return window.confirm('Remove '+count+' item'+(count===1?'':'s')+' from Owned? This cannot be undone.');
}
if (btnRemoveVis) btnRemoveVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' visible name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
if (btnRemoveSel) btnRemoveSel.addEventListener('click', function(){ var names=getSelectedNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' selected name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
if (btnExportVis) btnExportVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible', { names: names }).then(function(txt){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([txt],{type:'text/plain'})); a.download='owned_visible.txt'; a.click(); }); });
if (btnExportVisCsv) btnExportVisCsv.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible.csv', { names: names }).then(function(csv){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='owned_visible.csv'; a.click(); }); });
// Keyboard helpers: '/' focus search, Esc clear filters
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/') { if (fText){ e.preventDefault(); fText.focus(); fText.select && fText.select(); } }
else if (e.key === 'Escape'){ if (fText && fText.value){ fText.value=''; apply(); } }
});
// Hydrate from URL hash/localStorage
try{
var params = state.readHash();
var hv;
if (fSort){ hv = params.get('o_sort'); if (!hv) hv = state.get(fSort.getAttribute('data-pref'), 'name'); if (hv) fSort.value = hv; }
if (fType){ hv = params.get('o_type'); if (!hv) hv = state.get(fType.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fType.value = hv; }
if (fTag){ hv = params.get('o_tag'); if (!hv) hv = state.get(fTag.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fTag.value = hv; }
if (fColor){ hv = params.get('o_color'); if (!hv) hv = state.get(fColor.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fColor.value = hv; }
if (fText){ hv = params.get('o_q'); if (!hv && hv !== '') hv = state.get(fText.getAttribute('data-pref'), ''); if (typeof hv === 'string') fText.value = hv; }
}catch(_){ }
if (fSort) fSort.addEventListener('change', function(){ resort(); apply(); });
if (fType) fType.addEventListener('change', apply);
if (fTag) fTag.addEventListener('change', apply);
if (fColor) fColor.addEventListener('change', apply);
if (fText) fText.addEventListener('input', apply);
if (btnClear) btnClear.addEventListener('click', function(){
if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value='';
apply();
});
// Initial state
apply();
// Thumbnail retry now handled by global binder in base.html
// User tag chip UI removed by request.
})();
</script>
<style>
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.mana{ display:inline-block; width:14px; height:14px; border-radius:50%; box-sizing:border-box; }
.mana-W{ background:#f9fafb; border:1px solid #d1d5db; }
.mana-U{ background:#3b82f6; }
.mana-B{ background:#111827; }
.mana-R{ background:#ef4444; }
.mana-G{ background:#10b981; }
.mana-C{ background:#9ca3af; border:1px solid #6b7280; }
/* Subtle scrollbar styling for the owned list box */
#owned-box{ scrollbar-width: thin; scrollbar-color: rgba(148,163,184,.35) transparent; }
#owned-box:hover{ scrollbar-color: rgba(148,163,184,.6) transparent; }
#owned-box::-webkit-scrollbar{ width:8px; height:8px; }
#owned-box::-webkit-scrollbar-track{ background: transparent; }
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
/* Owned item layout */
#owned-grid{ justify-items:center; }
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:200px; margin:0 auto; }
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
.card-thumb{ display:block; width:100px; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
/* Highlight only the thumbnail when selected */
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
.mana-group{ display:flex; gap:4px; justify-content:center; }
.card-name{ display:block; }
</style>
{% endblock %}

View file

@ -0,0 +1,576 @@
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span>Legend:</span>
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Not owned</span>
</div>
<!-- Card Type Breakdown with names-only list and hover preview -->
<section style="margin-top:.5rem;">
<h5>Card Types</h5>
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
<span class="muted">View:</span>
<div class="seg" role="tablist" aria-label="Type view">
<button type="button" class="seg-btn" data-view="list" aria-selected="true">List</button>
<button type="button" class="seg-btn" data-view="thumbs">Thumbnails</button>
</div>
</div>
{% set tb = summary.type_breakdown %}
{% if tb and tb.counts %}
<style>
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.seg-btn { background:#12161c; color:#e5e7eb; border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
.seg-btn[aria-selected="true"] { background:#1f2937; }
.typeview { margin-top:.25rem; }
.typeview.hidden { display:none; }
.stack-wrap { --card-w: 160px; --card-h: 224px; --cols: 9; --overlap: .5; overflow: visible; padding: 6px 0 calc(var(--card-h) * (1 - var(--overlap))) 0; }
.stack-grid { display: grid; grid-template-columns: repeat(var(--cols), var(--card-w)); grid-auto-rows: calc(var(--card-h) * var(--overlap)); column-gap: 10px; }
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
.owned-flag { font-size:.95rem; opacity:.9; }
</style>
<div id="typeview-list" class="typeview">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
{% if clist %}
<style>
.list-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0; }
@media (max-width: 1199px) {
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
}
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) 1.6em; align-items:center; column-gap:.45rem; width:100%; }
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
</style>
<div class="list-grid">
{% for c in clist %}
<div class="list-row {% 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 %}">
{% 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)) %}
<span class="count">{{ cnt }}</span>
<span class="times">x</span>
<span class="name" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">{{ c.name }}</span>
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
<div id="typeview-thumbs" class="typeview hidden">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
{% if clist %}
<div class="stack-wrap">
<div class="stack-grid">
{% for c in clist %}
{% 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 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>
{% endfor %}
</div>
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="muted">No type data available.</div>
{% endif %}
</section>
<script>
(function(){
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
var thumbsBtn = document.querySelector('.seg-btn[data-view="thumbs"]');
var listView = document.getElementById('typeview-list');
var thumbsView = document.getElementById('typeview-thumbs');
function recalcThumbCols() {
if (thumbsView.classList.contains('hidden')) return;
var wraps = thumbsView.querySelectorAll('.stack-wrap');
wraps.forEach(function(sw){
var grid = sw.querySelector('.stack-grid');
if (!grid) return;
var gridStyles = window.getComputedStyle(grid);
var gap = parseFloat(gridStyles.columnGap) || 10;
var swStyles = window.getComputedStyle(sw);
var cardW = parseFloat(swStyles.getPropertyValue('--card-w')) || 160;
var width = sw.clientWidth;
if (!width || width < cardW) {
sw.style.setProperty('--cols', '1');
return;
}
var cols = Math.max(1, Math.floor((width + gap) / (cardW + gap)));
sw.style.setProperty('--cols', String(cols));
});
}
function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, ms); }; }
var debouncedRecalc = debounce(recalcThumbCols, 100);
window.addEventListener('resize', debouncedRecalc);
document.addEventListener('htmx:afterSwap', debouncedRecalc);
function applyMode(mode){
var isList = (mode !== 'thumbs');
listView.classList.toggle('hidden', !isList);
thumbsView.classList.toggle('hidden', isList);
if (listBtn) listBtn.setAttribute('aria-selected', isList ? 'true' : 'false');
if (thumbsBtn) thumbsBtn.setAttribute('aria-selected', isList ? 'false' : 'true');
try { localStorage.setItem('summaryTypeView', mode); } catch(e) {}
if (!isList) recalcThumbCols();
}
if (listBtn && thumbsBtn) {
listBtn.addEventListener('click', function(){ applyMode('list'); });
thumbsBtn.addEventListener('click', function(){ applyMode('thumbs'); });
}
var initial = 'list';
try { initial = localStorage.getItem('summaryTypeView') || 'list'; } catch(e) {}
applyMode(initial);
if (initial === 'thumbs') recalcThumbCols();
})();
</script>
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<h5>Mana Overview</h5>
{% set deck_colors = summary.colors or [] %}
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
<!-- Pips Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
{% set pd = summary.pip_distribution %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="muted">No pip data.</div>
{% endif %}
</div>
<!-- Sources Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
<div class="muted" style="font-weight:600;">Mana Sources</div>
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
</label>
</div>
{% set mg = summary.mana_generation %}
{% if mg %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
{# If colorless sources exist, append 'C' to colors for display #}
{% if 'C' in mg and (mg.get('C', 0) > 0) and ('C' not in colors) %}
{% set colors = colors + ['C'] %}
{% endif %}
{% set ns = namespace(max_src=0) %}
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
{% set mgc = mg['cards'] if 'cards' in mg else None %}
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
{% else %}
<div class="muted">No mana source data.</div>
{% endif %}
</div>
<!-- Curve Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
{% set mc = summary.mana_curve %}
{% if mc %}
{% set ts = mc.total_spells or 0 %}
{% set denom = (ts if ts and ts > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
{% set parts = [] %}
{% for c in cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
{% else %}
<div class="muted">No curve data.</div>
{% endif %}
</div>
</div>
</section>
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
<section style="margin-top:1rem;">
<h5>Test Hand</h5>
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
<button type="button" id="btn-new-hand">New Hand</button>
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
</div>
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
<div class="stack-grid" id="test-hand-grid"></div>
</div>
<script>
(function(){
var GC_SET = (function(){
try {
var els = document.querySelectorAll('#typeview-list .game-changer [data-card-name], #typeview-thumbs .game-changer [data-card-name]');
var s = new Set();
els.forEach(function(el){ var n = el.getAttribute('data-card-name'); if(n) s.add(n); });
return s;
} catch(e) { return new Set(); }
})();
var BASE_BASICS = ["Plains","Island","Swamp","Mountain","Forest","Wastes"];
function isBasicLand(name){
if (!name) return false;
if (BASE_BASICS.indexOf(name) >= 0) return true;
if (name.startsWith('Snow-Covered ')) {
var base = name.substring('Snow-Covered '.length);
return BASE_BASICS.indexOf(base) >= 0;
}
return false;
}
function collectDeck(){
var deck = [];
document.querySelectorAll('#typeview-list span[data-card-name]').forEach(function(el){
var name = el.getAttribute('data-card-name');
var cnt = parseInt(el.getAttribute('data-count') || '1', 10);
if (name) deck.push({ name: name, count: (isFinite(cnt) && cnt>0 ? cnt : 1) });
});
return deck;
}
function buildPool(deck){
var pool = [];
deck.forEach(function(it){
var n = Math.max(1, parseInt(it.count || 1, 10));
for (var i=0;i<n;i++){ pool.push(it.name); }
});
return pool;
}
function drawHand(deck){
var pool = buildPool(deck);
if (!pool.length) return [];
var picked = {};
var hand = [];
var attempts = 0;
while (hand.length < 7 && attempts < 500) {
attempts++;
var idx = Math.floor(Math.random() * pool.length);
var name = pool[idx];
if (!name) continue;
var allowDup = isBasicLand(name);
if (!allowDup && picked[name]) continue;
hand.push(name);
if (!allowDup) picked[name] = true;
pool.splice(idx, 1);
if (!pool.length) break;
}
return hand;
}
function compress(hand){
var map = {};
hand.forEach(function(n){ map[n] = (map[n]||0) + 1; });
var out = [];
Object.keys(map).forEach(function(n){ out.push({name:n, count: map[n]}); });
out.sort(function(a,b){ return hand.indexOf(a.name) - hand.indexOf(b.name); });
return out;
}
function render(hand){
var grid = document.getElementById('test-hand-grid');
if (!grid) return;
grid.innerHTML = '';
hand.forEach(function(name){
if (!name) return;
var div = document.createElement('div');
div.className = 'stack-card';
if (GC_SET && GC_SET.has(name)) {
div.className += ' game-changer';
}
div.innerHTML = (
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
);
grid.appendChild(div);
});
}
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
var btn = document.getElementById('btn-new-hand');
if (btn) btn.addEventListener('click', newHand);
newHand();
})();
</script>
</section>
<style>
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
/* Cross-highlight from charts to cards */
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
/* For list view, ensure baseline padding so no layout shift on highlight */
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
/* Ensure stack-card gets visible highlight */
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
</style>
<script>
(function() {
function ensureTip() {
var tip = document.getElementById('chart-tooltip');
if (!tip) {
tip = document.createElement('div');
tip.id = 'chart-tooltip';
tip.className = 'chart-tooltip';
document.body.appendChild(tip);
}
return tip;
}
var tip = ensureTip();
var hoverTimer = null;
var lastNames = [];
var lastType = '';
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
function position(e) {
tip.style.display = 'block';
var x = e.clientX + 12, y = e.clientY + 12;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
var rect = tip.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
}
function buildTip(el) {
// Render tooltip with safe DOM and a Copy button for card list
tip.innerHTML = '';
var t = el.getAttribute('data-type');
var header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '.25rem';
var listText = '';
if (t === 'pips') {
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'sources') {
header.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'curve') {
header.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else {
header.textContent = el.getAttribute('aria-label') || '';
}
tip.appendChild(header);
if (listText) {
var pre = document.createElement('pre');
pre.style.margin = '0 0 .35rem 0';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = listText;
tip.appendChild(pre);
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.fontSize = '12px';
btn.style.padding = '.2rem .4rem';
btn.style.border = '1px solid var(--border)';
btn.style.background = '#12161c';
btn.style.color = '#e5e7eb';
btn.style.borderRadius = '4px';
btn.addEventListener('click', function(e){
e.stopPropagation();
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(listText);
} else {
var ta = document.createElement('textarea');
ta.value = listText; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
}
btn.textContent = 'Copied!';
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
} catch(_) {}
});
tip.appendChild(btn);
}
}
function normalizeList(list) {
if (!Array.isArray(list)) return [];
return list.map(function(n){
if (!n) return '';
var s = String(n);
// Strip trailing " ×<num>" count suffix if present
s = s.replace(/\s×\d+$/,'');
return s.trim();
}).filter(Boolean);
}
function attach() {
document.querySelectorAll('[data-type]').forEach(function(el) {
el.addEventListener('mouseenter', function(e) {
buildTip(el);
position(e);
// Cross-highlight for mana curve bars -> card items
try {
if (el.getAttribute('data-type') === 'curve') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = 'curve';
highlightNames(lastNames, true);
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = el.getAttribute('data-type');
highlightNames(lastNames, true);
}
} catch(_) {}
});
el.addEventListener('mousemove', position);
el.addEventListener('mouseleave', function() {
clearHoverTimer();
hoverTimer = setTimeout(function(){
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
}, 200);
});
});
// Keep tooltip open while hovering it
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
tip.addEventListener('mouseleave', function(){
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
});
// Initialize Show C toggle
initShowCToggle();
}
function initShowCToggle(){
var cb = document.getElementById('toggle-show-c');
var container = document.querySelector('.sources-bars');
if (!cb || !container) return;
// Default ON; restore prior state
var pref = 'true';
try { var v = localStorage.getItem('showColorlessC'); if (v!==null) pref = v; } catch(_) {}
cb.checked = (pref !== 'false');
function apply(){
var on = cb.checked;
try { localStorage.setItem('showColorlessC', String(on)); } catch(_) {}
container.querySelectorAll('[data-color="C"]').forEach(function(el){
el.style.display = on ? '' : 'none';
});
}
cb.addEventListener('change', apply);
apply();
}
function highlightNames(names, on){
if (!Array.isArray(names) || names.length === 0) return;
// List view spans
try {
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
if (!n) return;
var match = names.indexOf(n) !== -1;
// Toggle highlight only on the inline span so it doesn't fill the entire grid cell
it.classList.toggle('chart-highlight', !!(on && match));
if (!on && !match) it.classList.remove('chart-highlight');
});
} catch(_) {}
// Thumbs view images
try {
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
if (!n) return;
var tile = it.closest('.stack-card') || it;
var match = names.indexOf(n) !== -1;
tile.classList.toggle('chart-highlight', !!(on && match));
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(); });
})();
</script>

View file

@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Setup / Tagging</h2>
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
<details open style="margin-top:.5rem;">
<summary>Current Status</summary>
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div class="muted">Status:</div>
<div id="setup-status-line" style="margin-top:.25rem;">Checking…</div>
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
</div>
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
</details>
</div>
</details>
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
<input type="checkbox" id="chk-force" checked /> Force run
</label>
</form>
<form method="get" action="/setup/running?start=1&force=1">
<button type="submit">Open Progress Page</button>
</form>
</div>
</section>
<script>
(function(){
function update(data){
var line = document.getElementById('setup-status-line');
var colorEl = document.getElementById('setup-color-line');
var logEl = document.getElementById('setup-log-tail');
var progEl = document.getElementById('setup-progress-line');
var timeEl = document.getElementById('setup-time-line');
var bar = document.getElementById('setup-progress-bar');
var barIn = document.getElementById('setup-progress-bar-inner');
var logWrap = document.getElementById('setup-log-wrap');
var logSummary = document.getElementById('setup-log-summary');
if (!line) return;
if (data && data.running) {
line.textContent = (data.message || 'Working…');
if (typeof data.percent === 'number') {
progEl.style.display = '';
var p = Math.max(0, Math.min(100, data.percent));
progEl.textContent = 'Progress: ' + p + '%';
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
}
if (typeof data.eta_seconds === 'number') {
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
}
} else {
progEl.style.display = 'none';
if (bar) bar.style.display = 'none';
}
if (data.started_at) {
timeEl.style.display = '';
timeEl.textContent = 'Started: ' + data.started_at;
} else {
timeEl.style.display = 'none';
}
if (data.color) {
colorEl.style.display = '';
colorEl.textContent = 'Current color: ' + data.color;
} else {
colorEl.style.display = 'none';
}
if (data.log_tail) {
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
if (logWrap) logWrap.style.display = '';
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
logEl.textContent = data.log_tail;
} else {
if (logWrap) logWrap.style.display = 'none';
}
} else if (data && data.phase === 'done') {
line.textContent = 'Setup complete.';
if (typeof data.percent === 'number') {
progEl.style.display = '';
var p2 = Math.max(0, Math.min(100, data.percent));
progEl.textContent = 'Progress: ' + p2 + '%';
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
} else {
progEl.style.display = 'none';
if (bar) bar.style.display = 'none';
}
if (data.started_at || data.finished_at) {
timeEl.style.display = '';
var t = [];
if (data.started_at) t.push('Started: ' + data.started_at);
if (data.finished_at) t.push('Finished: ' + data.finished_at);
timeEl.textContent = t.join(' • ');
} else {
timeEl.style.display = 'none';
}
colorEl.style.display = 'none';
if (logWrap) logWrap.style.display = 'none';
} else if (data && data.phase === 'error') {
line.textContent = (data.message || 'Setup error.');
if (data.color) {
colorEl.style.display = '';
colorEl.textContent = 'While working on: ' + data.color;
}
} else {
line.textContent = 'Idle';
progEl.style.display = 'none';
timeEl.style.display = 'none';
colorEl.style.display = 'none';
if (logWrap) logWrap.style.display = 'none';
}
}
function poll(){
fetch('/status/setup', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(update)
.catch(function(){});
}
function rapidPoll(times, delay){
var i = 0;
function tick(){
poll();
i++;
if (i < times) setTimeout(tick, delay);
}
tick();
}
window.startSetup = function(){
var btn = document.getElementById('btn-start-setup');
var line = document.getElementById('setup-status-line');
var force = document.getElementById('chk-force') && document.getElementById('chk-force').checked;
if (btn) btn.disabled = true;
if (line) line.textContent = 'Starting setup/tagging…';
// First try POST with JSON body
fetch('/setup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) })
.then(function(r){ if (!r.ok) throw new Error('POST failed'); return r.json().catch(function(){ return {}; }); })
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
.catch(function(){
// Fallback to GET if POST fails (proxy/middleware issues)
var url = '/setup/start' + (force ? '?force=1' : '');
fetch(url, { method: 'GET', cache: 'no-store' })
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
.catch(function(){});
})
.finally(function(){ if (btn) btn.disabled = false; });
};
setInterval(poll, 3000);
poll();
})();
</script>
{% endblock %}

View file

@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Preparing Card Database</h2>
<p class="muted">Initial setup and tagging may take several minutes on first run.</p>
<div id="setup-status" style="margin-top:1rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;" data-next-url="{{ next_url or '' }}">
<div class="muted">Status:</div>
<div id="setup-status-line" style="margin-top:.25rem;">Starting…</div>
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
</div>
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
</details>
</div>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<form method="get" action="/setup">
<button type="submit">Back to Setup</button>
</form>
{% if next_url %}
<form method="get" action="{{ next_url }}">
<button type="submit">Continue</button>
</form>
{% endif %}
</div>
</section>
<script>
(function(){
var container = document.getElementById('setup-status');
var nextUrl = (container && container.dataset.nextUrl) ? container.dataset.nextUrl : null;
if (nextUrl === '') nextUrl = null;
function update(data){
var line = document.getElementById('setup-status-line');
var colorEl = document.getElementById('setup-color-line');
var logEl = document.getElementById('setup-log-tail');
var progEl = document.getElementById('setup-progress-line');
var timeEl = document.getElementById('setup-time-line');
var bar = document.getElementById('setup-progress-bar');
var barIn = document.getElementById('setup-progress-bar-inner');
var logWrap = document.getElementById('setup-log-wrap');
var logSummary = document.getElementById('setup-log-summary');
if (!line) return;
if (data && data.running) {
line.textContent = (data.message || 'Working…');
if (typeof data.percent === 'number') {
progEl.style.display = '';
var p = Math.max(0, Math.min(100, data.percent));
progEl.textContent = 'Progress: ' + p + '%';
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
}
if (typeof data.eta_seconds === 'number') {
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
}
} else {
progEl.style.display = 'none';
if (bar) bar.style.display = 'none';
}
if (data.started_at) {
timeEl.style.display = '';
timeEl.textContent = 'Started: ' + data.started_at;
} else {
timeEl.style.display = 'none';
}
if (data.color) {
colorEl.style.display = '';
colorEl.textContent = 'Current color: ' + data.color;
} else {
colorEl.style.display = 'none';
}
if (data.log_tail) {
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
if (logWrap) logWrap.style.display = '';
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
logEl.textContent = data.log_tail;
} else {
if (logWrap) logWrap.style.display = 'none';
}
} else if (data && data.phase === 'done') {
line.textContent = 'Setup complete.';
if (typeof data.percent === 'number') {
progEl.style.display = '';
var p2 = Math.max(0, Math.min(100, data.percent));
progEl.textContent = 'Progress: ' + p2 + '%';
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
} else {
progEl.style.display = 'none';
if (bar) bar.style.display = 'none';
}
if (data.started_at || data.finished_at) {
timeEl.style.display = '';
var t = [];
if (data.started_at) t.push('Started: ' + data.started_at);
if (data.finished_at) t.push('Finished: ' + data.finished_at);
timeEl.textContent = t.join(' • ');
} else {
timeEl.style.display = 'none';
}
colorEl.style.display = 'none';
if (logWrap) logWrap.style.display = 'none';
if (nextUrl) {
setTimeout(function(){ window.location.href = nextUrl; }, 1200);
}
} else if (data && data.phase === 'error') {
line.textContent = (data.message || 'Setup error.');
if (data.color) {
colorEl.style.display = '';
colorEl.textContent = 'While working on: ' + data.color;
}
} else {
line.textContent = 'Idle';
colorEl.style.display = 'none';
if (logWrap) logWrap.style.display = 'none';
}
}
function poll(){
fetch('/status/setup', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(update)
.catch(function(){});
}
setInterval(poll, 3000);
poll();
})();
</script>
{% endblock %}

View file

@ -1,24 +1,57 @@
services:
mtg-deckbuilder:
# Command line driven build
# mtg-deckbuilder:
# build: .
# container_name: mtg-deckbuilder-main
# stdin_open: true # Equivalent to docker run -i
# tty: true # Equivalent to docker run -t
# volumes:
# - ${PWD}/deck_files:/app/deck_files
# - ${PWD}/logs:/app/logs
# - ${PWD}/csv_files:/app/csv_files
# # Optional: mount a config directory for headless JSON and owned cards
# - ${PWD}/config:/app/config
# - ${PWD}/owned_cards:/app/owned_cards
# environment:
# - PYTHONUNBUFFERED=1
# - TERM=xterm-256color
# - DEBIAN_FRONTEND=noninteractive
# # Set DECK_MODE=headless to auto-run non-interactive mode on start
# # - DECK_MODE=headless
# # Optional headless configuration (examples):
# # - DECK_CONFIG=/app/config/deck.json
# # - DECK_COMMANDER=Pantlaza
# # Ensure proper cleanup
# restart: "no"
web:
build: .
container_name: mtg-deckbuilder-main
stdin_open: true # Equivalent to docker run -i
tty: true # Equivalent to docker run -t
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files
# Optional: mount a config directory for headless JSON and owned cards
- ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards
container_name: mtg-deckbuilder-web
ports:
- "8080:8080"
environment:
- PYTHONUNBUFFERED=1
- TERM=xterm-256color
- DEBIAN_FRONTEND=noninteractive
# Set DECK_MODE=headless to auto-run non-interactive mode on start
# - DECK_MODE=headless
# Optional headless configuration (examples):
# - DECK_CONFIG=/app/config/deck.json
# - DECK_COMMANDER=Pantlaza
# Ensure proper cleanup
# Default theme for first-time visitors (no local preference yet): system|light|dark
# When set to 'light', it maps to the consolidated Light (Blend) palette in the UI
# - ENABLE_THEMES=1
# - THEME=dark
# Logging and error utilities
# - SHOW_LOGS=1
# - SHOW_DIAGNOSTICS=1
# - ENABLE_PWA=1
# 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
- ${PWD}/csv_files:/app/csv_files
- ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards
working_dir: /app
command: ["bash", "-lc", "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"]
restart: "no"

View file

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

6
pytest.ini Normal file
View file

@ -0,0 +1,6 @@
[pytest]
minversion = 7.0
# Disable built-in debugging plugin to avoid importing stdlib 'code' module,
# which conflicts with our package named 'code/'.
addopts = -q -p no:debugging
testpaths = code/tests

View file

@ -5,4 +5,10 @@ tqdm>=4.66.0
# Optional pretty output in reports; app falls back gracefully if missing
prettytable>=3.9.0
# Web UI stack (FastAPI + Jinja + HTMX served via CDN)
fastapi>=0.110.0
uvicorn[standard]>=0.28.0
Jinja2>=3.1.0
python-multipart>=0.0.9
# Development dependencies are in requirements-dev.txt

View file

@ -0,0 +1,41 @@
@echo off
setlocal ENABLEDELAYEDEXPANSION
echo MTG Python Deckbuilder - Web UI (Docker Hub)
echo ============================================
REM Create directories if they don't exist
if not exist "deck_files" mkdir deck_files
if not exist "logs" mkdir logs
if not exist "csv_files" mkdir csv_files
if not exist "config" mkdir config
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
echo Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% WEB_VIRTUALIZE=%WEB_VIRTUALIZE% THEME=%THEME% ENABLE_THEMES=%ENABLE_THEMES%
REM Optional theme flags (set before running):
REM set THEME=system|light|dark
REM set ENABLE_THEMES=1
docker run --rm ^
-p 8080:8080 ^
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% -e WEB_VIRTUALIZE=%WEB_VIRTUALIZE% ^
-e THEME=%THEME% -e ENABLE_THEMES=%ENABLE_THEMES% ^
-v "%cd%\deck_files:/app/deck_files" ^
-v "%cd%\logs:/app/logs" ^
-v "%cd%\csv_files:/app/csv_files" ^
-v "%cd%\owned_cards:/app/owned_cards" ^
-v "%cd%\config:/app/config" ^
mwisnowski/mtg-python-deckbuilder:latest ^
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
echo.
echo Open: http://localhost:8080
echo Tip: set SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 before running to hide those pages.
endlocal

30
run-web-from-dockerhub.sh Normal file
View file

@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
echo "MTG Python Deckbuilder - Web UI (Docker Hub)"
echo "==========================================="
# Create directories if they don't exist
mkdir -p deck_files logs csv_files config owned_cards
# Flags (override by exporting before running)
: "${SHOW_LOGS:=1}"
: "${SHOW_DIAGNOSTICS:=1}"
echo "Starting Web UI on http://localhost:8080"
echo "Flags: SHOW_LOGS=${SHOW_LOGS} SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS}"
docker run --rm \
-p 8080:8080 \
-e SHOW_LOGS=${SHOW_LOGS} -e SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS} \
-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" \
mwisnowski/mtg-python-deckbuilder:latest \
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
echo
echo "Open: http://localhost:8080"
echo "Tip: export SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 to hide those pages."