mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
release: v2.0.1 — Web UI major upgrade, theming reset, diagnostics polish, docs refreshed
This commit is contained in:
parent
721e1884af
commit
171fa5cbaf
17 changed files with 393 additions and 58 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,3 +13,4 @@ dist/
|
||||||
logs/
|
logs/
|
||||||
!config/deck.json
|
!config/deck.json
|
||||||
RELEASE_NOTES.md
|
RELEASE_NOTES.md
|
||||||
|
*.bkp
|
|
@ -12,6 +12,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.1] - 2025-08-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Web UI performance: optional virtualized grids/lists in Step 5 and Owned (enable with `WEB_VIRTUALIZE=1`).
|
- 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 per‑grid overlays and a global summary bubble with visible range, totals, render time, and counters.
|
- Virtualization diagnostics overlay (when `SHOW_DIAGNOSTICS=1`); press `v` to toggle per‑grid overlays and a global summary bubble with visible range, totals, render time, and counters.
|
||||||
|
@ -49,6 +51,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Request-ID middleware assigns `X-Request-ID` to all responses and includes it in JSON error payloads
|
- 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
|
- `/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
|
- 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`.
|
Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outline.md`.
|
||||||
|
|
||||||
|
@ -77,6 +80,7 @@ Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outlin
|
||||||
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
|
- 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
|
- 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
|
- 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
|
- 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
|
### Fixed
|
||||||
|
|
|
@ -34,7 +34,7 @@ Then open http://localhost:8080
|
||||||
|
|
||||||
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
|
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`.
|
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.
|
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:
|
Web UI feature highlights:
|
||||||
- Locks: Click a card or the lock control in Step 5; locks persist across reruns.
|
- Locks: Click a card or the lock control in Step 5; locks persist across reruns.
|
||||||
|
@ -87,7 +87,7 @@ Docker Hub (PowerShell) example:
|
||||||
```powershell
|
```powershell
|
||||||
docker run --rm `
|
docker run --rm `
|
||||||
-p 8080:8080 `
|
-p 8080:8080 `
|
||||||
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 `
|
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 -e ENABLE_THEMES=1 -e THEME=system `
|
||||||
-v "${PWD}/deck_files:/app/deck_files" `
|
-v "${PWD}/deck_files:/app/deck_files" `
|
||||||
-v "${PWD}/logs:/app/logs" `
|
-v "${PWD}/logs:/app/logs" `
|
||||||
-v "${PWD}/csv_files:/app/csv_files" `
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
|
@ -125,6 +125,8 @@ Health check:
|
||||||
GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "uptime_seconds": 123 }
|
GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "uptime_seconds": 123 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Theme preference reset (client-side): use the header’s Reset Theme control to clear the saved browser preference; the server default (THEME) applies on next paint.
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
- `/app/deck_files` ↔ `./deck_files`
|
- `/app/deck_files` ↔ `./deck_files`
|
||||||
- `/app/logs` ↔ `./logs`
|
- `/app/logs` ↔ `./logs`
|
||||||
|
|
|
@ -49,6 +49,9 @@ WORKDIR /app/code
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "main.py"]
|
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:
|
# 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
|
# uvicorn code.web.app:app --host 0.0.0.0 --port 8080
|
||||||
# Phase 9: enable web list virtualization with env WEB_VIRTUALIZE=1
|
# Phase 9: enable web list virtualization with env WEB_VIRTUALIZE=1
|
||||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -6,6 +6,7 @@
|
||||||
- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config.
|
- 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.
|
- 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`).
|
- 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`.
|
- 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.
|
- 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 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
|
- Phase 8 UI upgrade: Unified “New Deck” modal (steps 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation.
|
- 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).
|
- 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`.
|
- 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.
|
||||||
|
|
||||||
## What’s new
|
## What’s 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.
|
- 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.
|
||||||
|
|
|
@ -47,6 +47,7 @@ Run the browser UI by mapping a port and starting uvicorn:
|
||||||
docker run --rm `
|
docker run --rm `
|
||||||
-p 8080:8080 `
|
-p 8080:8080 `
|
||||||
-e WEB_VIRTUALIZE=1 ` # optional virtualization
|
-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}/deck_files:/app/deck_files" `
|
||||||
-v "${PWD}/logs:/app/logs" `
|
-v "${PWD}/logs:/app/logs" `
|
||||||
-v "${PWD}/csv_files:/app/csv_files" `
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
|
@ -57,6 +58,8 @@ docker run --rm `
|
||||||
```
|
```
|
||||||
Then open http://localhost:8080
|
Then open http://localhost:8080
|
||||||
|
|
||||||
|
Tip: The header includes a Reset Theme control to clear your browser’s saved preference and re-apply the server’s default (or OS when THEME=system).
|
||||||
|
|
||||||
## Method 2: Command Prompt
|
## Method 2: Command Prompt
|
||||||
```cmd
|
```cmd
|
||||||
REM Create and navigate to workspace
|
REM Create and navigate to workspace
|
||||||
|
|
|
@ -67,7 +67,11 @@ def test_status_sys_summary_and_flags():
|
||||||
SHOW_LOGS="1",
|
SHOW_LOGS="1",
|
||||||
SHOW_DIAGNOSTICS="1",
|
SHOW_DIAGNOSTICS="1",
|
||||||
SHOW_SETUP="1",
|
SHOW_SETUP="1",
|
||||||
|
ENABLE_THEMES="1",
|
||||||
|
ENABLE_PWA="1",
|
||||||
|
ENABLE_PRESETS="1",
|
||||||
APP_VERSION="testver",
|
APP_VERSION="testver",
|
||||||
|
THEME="dark",
|
||||||
)
|
)
|
||||||
client = TestClient(app_module.app)
|
client = TestClient(app_module.app)
|
||||||
r = client.get("/status/sys")
|
r = client.get("/status/sys")
|
||||||
|
@ -80,3 +84,8 @@ def test_status_sys_summary_and_flags():
|
||||||
assert flags.get("SHOW_LOGS") is True
|
assert flags.get("SHOW_LOGS") is True
|
||||||
assert flags.get("SHOW_DIAGNOSTICS") is True
|
assert flags.get("SHOW_DIAGNOSTICS") is True
|
||||||
assert flags.get("SHOW_SETUP") 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"
|
||||||
|
|
|
@ -48,6 +48,15 @@ SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
||||||
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||||
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
||||||
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), 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
|
# Expose as Jinja globals so all templates can reference without passing per-view
|
||||||
templates.env.globals.update({
|
templates.env.globals.update({
|
||||||
|
@ -55,6 +64,10 @@ templates.env.globals.update({
|
||||||
"show_setup": SHOW_SETUP,
|
"show_setup": SHOW_SETUP,
|
||||||
"show_diagnostics": SHOW_DIAGNOSTICS,
|
"show_diagnostics": SHOW_DIAGNOSTICS,
|
||||||
"virtualize": SHOW_VIRTUALIZE,
|
"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) ---
|
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
|
||||||
|
@ -132,6 +145,10 @@ async def status_sys():
|
||||||
"SHOW_LOGS": bool(SHOW_LOGS),
|
"SHOW_LOGS": bool(SHOW_LOGS),
|
||||||
"SHOW_SETUP": bool(SHOW_SETUP),
|
"SHOW_SETUP": bool(SHOW_SETUP),
|
||||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
"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:
|
except Exception:
|
||||||
|
|
13
code/web/static/manifest.webmanifest
Normal file
13
code/web/static/manifest.webmanifest
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
/* Base */
|
/* Base */
|
||||||
:root{
|
:root{
|
||||||
/* MTG color palette (approx from provided values) */
|
/* MTG color palette (approx from provided values) */
|
||||||
|
--banner-h: 52px;
|
||||||
--sidebar-w: 260px;
|
--sidebar-w: 260px;
|
||||||
--green-main: rgb(0,115,62);
|
--green-main: rgb(0,115,62);
|
||||||
--green-light: rgb(196,211,202);
|
--green-light: rgb(196,211,202);
|
||||||
|
@ -21,16 +22,66 @@
|
||||||
--ok: #16a34a; /* success */
|
--ok: #16a34a; /* success */
|
||||||
--warn: #f59e0b; /* warning */
|
--warn: #f59e0b; /* warning */
|
||||||
--err: #ef4444; /* error */
|
--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}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); }
|
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 */
|
/* Honor HTML hidden attribute across the app */
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
/* Accessible focus ring for keyboard navigation */
|
/* Accessible focus ring for keyboard navigation */
|
||||||
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||||
/* Top banner */
|
/* Top banner */
|
||||||
.top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); }
|
.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 .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; }
|
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
@ -39,9 +90,22 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
|
||||||
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
|
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.layout{ display:grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: calc(100vh - 52px); }
|
.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
|
||||||
.sidebar{ background: var(--panel); border-right: 1px solid var(--border); padding: 1rem; position:sticky; top:0; align-self:start; height:100vh; overflow:auto; width: var(--sidebar-w); }
|
.sidebar{
|
||||||
.content{ padding: 1.25rem 1.5rem; }
|
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; }
|
.brand h1{ display:none; }
|
||||||
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
|
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
|
||||||
|
@ -53,13 +117,13 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
|
||||||
.dot.black{ background: var(--black-light); }
|
.dot.black{ background: var(--black-light); }
|
||||||
|
|
||||||
.nav{ display:flex; flex-direction:column; gap:.35rem; }
|
.nav{ display:flex; flex-direction:column; gap:.35rem; }
|
||||||
.nav a{ color: var(--text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
||||||
.nav a:hover{ background: #202227; border-color: var(--border); }
|
.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 */
|
/* Simple two-column layout for inspect panel */
|
||||||
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
||||||
.two-col .grow { min-width: 0; }
|
.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: #111; }
|
.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; } }
|
@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Left-rail variant puts the image first */
|
/* Left-rail variant puts the image first */
|
||||||
|
@ -74,7 +138,7 @@ button:hover{ filter:brightness(1.05); }
|
||||||
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
|
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
|
||||||
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events: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; }
|
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
|
||||||
select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
|
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; }
|
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
|
||||||
small, .muted{ color: var(--muted); }
|
small, .muted{ color: var(--muted); }
|
||||||
|
|
||||||
|
@ -108,8 +172,8 @@ small, .muted{ color: var(--muted); }
|
||||||
|
|
||||||
/* Home actions */
|
/* Home actions */
|
||||||
.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
|
.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:#0f1115; padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
|
.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:#3a3c42; background:#12151b; }
|
.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; }
|
.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 for added cards (responsive, compact tiles) */
|
||||||
|
@ -123,7 +187,7 @@ small, .muted{ color: var(--muted); }
|
||||||
.card-tile{
|
.card-tile{
|
||||||
width:170px;
|
width:170px;
|
||||||
position: relative;
|
position: relative;
|
||||||
background:#0f1115;
|
background: var(--panel);
|
||||||
border:1px solid var(--border);
|
border:1px solid var(--border);
|
||||||
border-radius:6px;
|
border-radius:6px;
|
||||||
padding:.25rem .25rem .4rem;
|
padding:.25rem .25rem .4rem;
|
||||||
|
@ -165,13 +229,13 @@ small, .muted{ color: var(--muted); }
|
||||||
gap:.75rem;
|
gap:.75rem;
|
||||||
}
|
}
|
||||||
.candidate-tile{
|
.candidate-tile{
|
||||||
background:#0f1115;
|
background: var(--panel);
|
||||||
border:1px solid var(--border);
|
border:1px solid var(--border);
|
||||||
border-radius:8px;
|
border-radius:8px;
|
||||||
padding:.4rem;
|
padding:.4rem;
|
||||||
}
|
}
|
||||||
.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
|
.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:#111; display:block; margin:0 auto; }
|
.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 .meta{ text-align:center; margin-top:.35rem; }
|
||||||
.candidate-tile .name{ font-weight:600; font-size:.95rem; }
|
.candidate-tile .name{ font-weight:600; font-size:.95rem; }
|
||||||
.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
|
.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
|
||||||
|
@ -186,7 +250,7 @@ small, .muted{ color: var(--muted); }
|
||||||
/* Stage Navigator */
|
/* Stage Navigator */
|
||||||
.stage-nav { margin:.5rem 0 1rem; }
|
.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 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:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
|
.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.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 .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 .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
|
||||||
|
@ -198,12 +262,12 @@ small, .muted{ color: var(--muted); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress bar */
|
/* Progress bar */
|
||||||
.progress { position: relative; height: 10px; background: #0f1115; border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
.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 .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; }
|
.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
|
||||||
|
|
||||||
/* Chips */
|
/* Chips */
|
||||||
.chip { display:inline-flex; align-items:center; gap:.35rem; background:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
|
.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; }
|
.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
|
||||||
|
|
||||||
/* Cards toolbar */
|
/* Cards toolbar */
|
||||||
|
@ -217,17 +281,17 @@ small, .muted{ color: var(--muted); }
|
||||||
.group-header{ display:flex; align-items:center; gap:.5rem; }
|
.group-header{ display:flex; align-items:center; gap:.5rem; }
|
||||||
.group-header h5{ margin:.4rem 0; }
|
.group-header h5{ margin:.4rem 0; }
|
||||||
.group-header .count{ color: var(--muted); font-size:12px; }
|
.group-header .count{ color: var(--muted); font-size:12px; }
|
||||||
.group-header .toggle{ margin-left:auto; background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
|
.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; }
|
.group-grid[data-collapsed]{ display:none; }
|
||||||
.hide-reasons .card-tile .reason{ display:none; }
|
.hide-reasons .card-tile .reason{ display:none; }
|
||||||
.card-tile.force-show .reason{ display:block !important; }
|
.card-tile.force-show .reason{ display:block !important; }
|
||||||
.card-tile.force-hide .reason{ display:none !important; }
|
.card-tile.force-hide .reason{ display:none !important; }
|
||||||
.btn-why{ background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
|
.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{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
|
||||||
.chips-inline .chip{ cursor:pointer; user-select:none; }
|
.chips-inline .chip{ cursor:pointer; user-select:none; }
|
||||||
|
|
||||||
/* Inline error banner */
|
/* Inline error banner */
|
||||||
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
|
.inline-error-banner{ 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; }
|
.inline-error-banner .muted{ color:#fda4af; }
|
||||||
|
|
||||||
/* Alternatives panel */
|
/* Alternatives panel */
|
||||||
|
|
10
code/web/static/sw.js
Normal file
10
code/web/static/sw.js
Normal 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.
|
||||||
|
});
|
|
@ -1,11 +1,36 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="{% if default_theme == 'light' %}light-blend{% elif default_theme == 'dark' %}dark{% else %}light-blend{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>MTG Deckbuilder</title>
|
<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 src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
|
<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 -->
|
<!-- Performance hints -->
|
||||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||||
|
@ -13,6 +38,9 @@
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||||
|
{% if enable_pwa %}
|
||||||
|
<link rel="manifest" href="/static/manifest.webmanifest" />
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||||
<header class="top-banner">
|
<header class="top-banner">
|
||||||
|
@ -21,8 +49,23 @@
|
||||||
<div style="display:flex; align-items:center; gap:.5rem">
|
<div style="display:flex; align-items:center; gap:.5rem">
|
||||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||||
<button type="button" class="btn" style="margin-left:.5rem;" title="Open a saved permalink"
|
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -60,16 +103,17 @@
|
||||||
<style>
|
<style>
|
||||||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
.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-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:#0f1115; }
|
.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: #0f1115; color: #e5e7eb; 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 { 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 ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||||
.card-meta li { margin:.1rem 0; }
|
.card-meta li { margin:.1rem 0; }
|
||||||
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
|
.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 .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||||
.card-meta .themes-label { color:#f3f4f6; font-size: 20px; letter-spacing: .05em; }
|
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
|
||||||
.card-meta .line + .line { margin-top:.35rem; }
|
.card-meta .line + .line { margin-top:.35rem; }
|
||||||
.site-footer { margin: 12px 16px 0; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
.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; }
|
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||||
|
footer.site-footer { flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
@ -267,6 +311,109 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/app.js?v=20250826-4"></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>
|
<script>
|
||||||
// Show pending toast after full page reloads when actions replace the whole document
|
// Show pending toast after full page reloads when actions replace the whole document
|
||||||
(function(){
|
(function(){
|
||||||
|
|
|
@ -3,11 +3,15 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>Diagnostics</h2>
|
<h2>Diagnostics</h2>
|
||||||
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
||||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
<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>
|
<h3 style="margin-top:0">System summary</h3>
|
||||||
<div id="sysSummary" class="muted">Loading…</div>
|
<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:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
</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>
|
<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 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 style="display:flex; gap:1rem; flex-wrap:wrap">
|
||||||
|
@ -16,7 +20,13 @@
|
||||||
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
{% 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>
|
<h3 style="margin-top:0">Error triggers</h3>
|
||||||
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
<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" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
|
||||||
|
@ -48,6 +58,46 @@
|
||||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
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();
|
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
|
// Perf probe: listen to scroll on a card grid if present
|
||||||
try{
|
try{
|
||||||
var fpsEl = document.getElementById('perf-fps');
|
var fpsEl = document.getElementById('perf-fps');
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
services:
|
services:
|
||||||
mtg-deckbuilder:
|
# mtg-deckbuilder:
|
||||||
build: .
|
# build: .
|
||||||
container_name: mtg-deckbuilder-main
|
# container_name: mtg-deckbuilder-main
|
||||||
stdin_open: true # Equivalent to docker run -i
|
# stdin_open: true # Equivalent to docker run -i
|
||||||
tty: true # Equivalent to docker run -t
|
# tty: true # Equivalent to docker run -t
|
||||||
volumes:
|
# volumes:
|
||||||
- ${PWD}/deck_files:/app/deck_files
|
# - ${PWD}/deck_files:/app/deck_files
|
||||||
- ${PWD}/logs:/app/logs
|
# - ${PWD}/logs:/app/logs
|
||||||
- ${PWD}/csv_files:/app/csv_files
|
# - ${PWD}/csv_files:/app/csv_files
|
||||||
# Optional: mount a config directory for headless JSON and owned cards
|
# # Optional: mount a config directory for headless JSON and owned cards
|
||||||
- ${PWD}/config:/app/config
|
# - ${PWD}/config:/app/config
|
||||||
- ${PWD}/owned_cards:/app/owned_cards
|
# - ${PWD}/owned_cards:/app/owned_cards
|
||||||
environment:
|
# environment:
|
||||||
- PYTHONUNBUFFERED=1
|
# - PYTHONUNBUFFERED=1
|
||||||
- TERM=xterm-256color
|
# - TERM=xterm-256color
|
||||||
- DEBIAN_FRONTEND=noninteractive
|
# - DEBIAN_FRONTEND=noninteractive
|
||||||
# Set DECK_MODE=headless to auto-run non-interactive mode on start
|
# # Set DECK_MODE=headless to auto-run non-interactive mode on start
|
||||||
# - DECK_MODE=headless
|
# # - DECK_MODE=headless
|
||||||
# Optional headless configuration (examples):
|
# # Optional headless configuration (examples):
|
||||||
# - DECK_CONFIG=/app/config/deck.json
|
# # - DECK_CONFIG=/app/config/deck.json
|
||||||
# - DECK_COMMANDER=Pantlaza
|
# # - DECK_COMMANDER=Pantlaza
|
||||||
# Ensure proper cleanup
|
# # Ensure proper cleanup
|
||||||
restart: "no"
|
# restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
|
@ -32,9 +32,14 @@ services:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- TERM=xterm-256color
|
- TERM=xterm-256color
|
||||||
- DEBIAN_FRONTEND=noninteractive
|
- DEBIAN_FRONTEND=noninteractive
|
||||||
|
# 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
|
# Logging and error utilities
|
||||||
# - SHOW_LOGS=1
|
# - SHOW_LOGS=1
|
||||||
# - SHOW_DIAGNOSTICS=1
|
# - SHOW_DIAGNOSTICS=1
|
||||||
|
# - ENABLE_PWA=1
|
||||||
# Speed up setup/tagging in Web UI via parallel workers
|
# Speed up setup/tagging in Web UI via parallel workers
|
||||||
- WEB_TAG_PARALLEL=1
|
- WEB_TAG_PARALLEL=1
|
||||||
- WEB_TAG_WORKERS=4
|
- WEB_TAG_WORKERS=4
|
||||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mtg-deckbuilder"
|
name = "mtg-deckbuilder"
|
||||||
version = "1.1.2"
|
version = "2.0.1"
|
||||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
|
|
@ -17,11 +17,16 @@ if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
|
||||||
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
|
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
|
||||||
|
|
||||||
echo Starting Web UI on http://localhost:8080
|
echo Starting Web UI on http://localhost:8080
|
||||||
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% WEB_VIRTUALIZE=%WEB_VIRTUALIZE%
|
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 ^
|
docker run --rm ^
|
||||||
-p 8080:8080 ^
|
-p 8080:8080 ^
|
||||||
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% -e WEB_VIRTUALIZE=%WEB_VIRTUALIZE% ^
|
-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%\deck_files:/app/deck_files" ^
|
||||||
-v "%cd%\logs:/app/logs" ^
|
-v "%cd%\logs:/app/logs" ^
|
||||||
-v "%cd%\csv_files:/app/csv_files" ^
|
-v "%cd%\csv_files:/app/csv_files" ^
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue