mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
Merge pull request #31 from mwisnowski/features/ui-enhancements
UI Responsiveness & Performance Enhancements
This commit is contained in:
commit
ae95837cf2
30 changed files with 2607 additions and 980 deletions
|
|
@ -43,6 +43,7 @@ ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
|
|||
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
||||
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
|
||||
SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons)
|
||||
WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only)
|
||||
|
||||
############################
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,9 +1,4 @@
|
|||
- Random Modes (alpha): added env flags RANDOM_MODES, RANDOM_UI, RANDOM_MAX_ATTEMPTS, RANDOM_TIMEOUT_MS.
|
||||
- Determinism: CSV_FILES_DIR override to point tests to csv_files/testdata; permalink now carries optional random fields (seed/theme/constraints).
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
This format follows Keep a Changelog principles and aims for Semantic Versioning.
|
||||
|
||||
## How we version
|
||||
|
|
@ -14,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Summary
|
||||
- _No changes yet_
|
||||
- Responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists.
|
||||
- Optimistic include/exclude experience with HTMX caching, prefetch hints, and telemetry instrumentation for must-have interactions.
|
||||
- Commander catalog skeleton placeholders and lazy commander art loading to smooth catalog fetch latency.
|
||||
- Commander catalog default view now prewarms and pulls from an in-memory cache so repeat visits respond in under 200 ms.
|
||||
- Virtualization helper now respects `data-virtualize-*` hints and powers deck summary lists without loading all rows at once.
|
||||
- Step 5 deck summary now streams via an HTMX fragment so the main review payload stays lean while virtualization kicks in post-swap.
|
||||
- Mana analytics now load on-demand with collapsible sections, reducing initial deck review time by ~30-40%.
|
||||
- Interactive chart tooltips with click-to-pin highlighting make cross-referencing cards between charts and deck lists easier.
|
||||
|
||||
### Added
|
||||
- _None_
|
||||
- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel.
|
||||
- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers for include/exclude state changes and frontend beacons.
|
||||
- Commander catalog results wrap in a deferred skeleton list, and commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`.
|
||||
- Collapsible accordions for Mana Overview and Test Hand sections defer content loading until expanded.
|
||||
- Click-to-pin chart tooltips with consistent corner positioning (lower-left desktop, lower-right mobile) and working copy buttons.
|
||||
- Virtualized card lists automatically render only visible items when 12+ cards are present.changes to this project will be documented in this file.
|
||||
|
||||
### Changed
|
||||
- _None_
|
||||
- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type.
|
||||
- Card grids use modern browser containment rules to minimize layout recalculations on large decks.
|
||||
- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees.
|
||||
- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms.
|
||||
- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively.
|
||||
- Chart hover zones expanded to full column width for easier interaction.
|
||||
|
||||
### Fixed
|
||||
- _None_
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ See `.env.example` for the full catalog. Common knobs:
|
|||
| `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. |
|
||||
| `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. |
|
||||
| `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. |
|
||||
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
|
||||
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
|
||||
|
||||
### Random build controls
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
|
|||
| `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. |
|
||||
| `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. |
|
||||
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
|
||||
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
|
||||
| `THEME` | `dark` | Default UI theme (`system`, `light`, or `dark`). |
|
||||
|
||||
### Random build tuning
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ from contextlib import asynccontextmanager
|
|||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore
|
||||
from .services.commander_catalog_loader import load_commander_catalog # type: ignore
|
||||
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
|
||||
|
||||
# Resolve template/static dirs relative to this file
|
||||
|
|
@ -42,6 +43,19 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
|||
prewarm_common_filters()
|
||||
except Exception:
|
||||
pass
|
||||
# Warm commander + theme catalogs so the first commander catalog request skips disk reads
|
||||
try:
|
||||
load_commander_catalog()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
load_index()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
commanders_routes.prewarm_default_page() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Warm preview card index once (updated Phase A: moved to card_index module)
|
||||
try: # local import to avoid cost if preview unused
|
||||
from .services.card_index import maybe_build_index # type: ignore
|
||||
|
|
@ -112,6 +126,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
|||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
|
||||
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
|
||||
|
|
@ -251,6 +266,7 @@ templates.env.globals.update({
|
|||
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
|
||||
"enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
"random_modes": RANDOM_MODES,
|
||||
"random_ui": RANDOM_UI,
|
||||
|
|
@ -840,6 +856,7 @@ async def status_sys():
|
|||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ENABLE_PARTNER_MECHANICS": bool(ENABLE_PARTNER_MECHANICS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"SHOW_MUST_HAVE_BUTTONS": bool(SHOW_MUST_HAVE_BUTTONS),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
|
||||
"USER_THEME_LIMIT": int(USER_THEME_LIMIT),
|
||||
|
|
@ -2186,6 +2203,7 @@ from .routes import owned as owned_routes # noqa: E402
|
|||
from .routes import themes as themes_routes # noqa: E402
|
||||
from .routes import commanders as commanders_routes # noqa: E402
|
||||
from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402
|
||||
from .routes import telemetry as telemetry_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
|
|
@ -2194,6 +2212,7 @@ app.include_router(owned_routes.router)
|
|||
app.include_router(themes_routes.router)
|
||||
app.include_router(commanders_routes.router)
|
||||
app.include_router(partner_suggestions_routes.router)
|
||||
app.include_router(telemetry_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any, Iterable
|
||||
import json
|
||||
from ..app import (
|
||||
ALLOW_MUST_HAVES,
|
||||
ENABLE_CUSTOM_THEMES,
|
||||
SHOW_MUST_HAVE_BUTTONS,
|
||||
USER_THEME_LIMIT,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
_sanitize_theme,
|
||||
|
|
@ -13,6 +15,7 @@ from ..app import (
|
|||
ENABLE_PARTNER_SUGGESTIONS,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_base_ctx,
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
step5_empty_ctx,
|
||||
|
|
@ -37,6 +40,7 @@ from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as
|
|||
from ..services.telemetry import (
|
||||
log_commander_create_deck,
|
||||
log_partner_suggestion_selected,
|
||||
log_include_exclude_toggle,
|
||||
)
|
||||
from ..services.partner_suggestions import get_partner_suggestions
|
||||
from urllib.parse import urlparse, quote_plus
|
||||
|
|
@ -136,6 +140,90 @@ def warm_validation_name_cache() -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
|
||||
if not payload or response is None:
|
||||
return
|
||||
try:
|
||||
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
|
||||
except Exception:
|
||||
existing = None
|
||||
try:
|
||||
if existing:
|
||||
try:
|
||||
data = json.loads(existing)
|
||||
except Exception:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
data.update(payload)
|
||||
response.headers["HX-Trigger"] = json.dumps(data)
|
||||
return
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str:
|
||||
text = message or "Deck summary will appear after the build completes."
|
||||
return (
|
||||
f'<div id="deck-summary" data-summary '
|
||||
f'hx-get="/build/step5/summary?token={token}" '
|
||||
'hx-trigger="load, step5:refresh from:body" hx-swap="outerHTML">'
|
||||
f'<div class="muted" style="margin-top:1rem;">{_esc(text)}</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
|
||||
includes = list(sess.get("include_cards") or [])
|
||||
excludes = list(sess.get("exclude_cards") or [])
|
||||
state = {
|
||||
"includes": includes,
|
||||
"excludes": excludes,
|
||||
"enforcement_mode": (sess.get("enforcement_mode") or "warn"),
|
||||
"allow_illegal": bool(sess.get("allow_illegal")),
|
||||
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
||||
}
|
||||
return state, includes, excludes
|
||||
|
||||
|
||||
def _render_include_exclude_summary(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
sid: str,
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
includes: list[str] | None = None,
|
||||
excludes: list[str] | None = None,
|
||||
) -> HTMLResponse:
|
||||
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
|
||||
if state is None or includes is None or excludes is None:
|
||||
state, includes, excludes = _must_have_state(sess)
|
||||
ctx["must_have_state"] = state
|
||||
ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
|
||||
ctx["include_cards"] = includes
|
||||
ctx["exclude_cards"] = excludes
|
||||
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
def _current_builder_summary(sess: dict) -> Any | None:
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
builder = ctx.get("builder") if isinstance(ctx, dict) else None
|
||||
if builder is None:
|
||||
return None
|
||||
summary_fn = getattr(builder, "build_deck_summary", None)
|
||||
if callable(summary_fn):
|
||||
return summary_fn()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
_COLOR_NAME_MAP = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
|
|
@ -684,6 +772,117 @@ def _resolve_partner_selection(
|
|||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@router.post("/must-haves/toggle", response_class=HTMLResponse)
|
||||
async def toggle_must_haves(
|
||||
request: Request,
|
||||
card_name: str = Form(...),
|
||||
list_type: str = Form(...),
|
||||
enabled: str = Form("1"),
|
||||
):
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
|
||||
|
||||
name = str(card_name or "").strip()
|
||||
if not name:
|
||||
return JSONResponse({"error": "Card name is required"}, status_code=400)
|
||||
|
||||
list_key = str(list_type or "").strip().lower()
|
||||
if list_key not in {"include", "exclude"}:
|
||||
return JSONResponse({"error": "Unsupported toggle type"}, status_code=400)
|
||||
|
||||
enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
|
||||
if not sid:
|
||||
sid = new_sid()
|
||||
sess = get_session(sid)
|
||||
|
||||
includes = list(sess.get("include_cards") or [])
|
||||
excludes = list(sess.get("exclude_cards") or [])
|
||||
include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()}
|
||||
exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()}
|
||||
key = name.lower()
|
||||
display_name = include_lookup.get(key) or exclude_lookup.get(key) or name
|
||||
|
||||
changed = False
|
||||
include_limit = 10
|
||||
exclude_limit = 15
|
||||
|
||||
def _remove_casefold(items: list[str], item_key: str) -> list[str]:
|
||||
return [c for c in items if str(c).strip().lower() != item_key]
|
||||
|
||||
if list_key == "include":
|
||||
if enabled_flag:
|
||||
if key not in include_lookup:
|
||||
if len(include_lookup) >= include_limit:
|
||||
return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400)
|
||||
includes.append(name)
|
||||
include_lookup[key] = name
|
||||
changed = True
|
||||
if key in exclude_lookup:
|
||||
excludes = _remove_casefold(excludes, key)
|
||||
exclude_lookup.pop(key, None)
|
||||
changed = True
|
||||
else:
|
||||
if key in include_lookup:
|
||||
includes = _remove_casefold(includes, key)
|
||||
include_lookup.pop(key, None)
|
||||
changed = True
|
||||
else: # exclude
|
||||
if enabled_flag:
|
||||
if key not in exclude_lookup:
|
||||
if len(exclude_lookup) >= exclude_limit:
|
||||
return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400)
|
||||
excludes.append(name)
|
||||
exclude_lookup[key] = name
|
||||
changed = True
|
||||
if key in include_lookup:
|
||||
includes = _remove_casefold(includes, key)
|
||||
include_lookup.pop(key, None)
|
||||
changed = True
|
||||
else:
|
||||
if key in exclude_lookup:
|
||||
excludes = _remove_casefold(excludes, key)
|
||||
exclude_lookup.pop(key, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
sess["include_cards"] = includes
|
||||
sess["exclude_cards"] = excludes
|
||||
if "include_exclude_diagnostics" in sess:
|
||||
try:
|
||||
del sess["include_exclude_diagnostics"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = _render_include_exclude_summary(request, sess, sid)
|
||||
|
||||
try:
|
||||
log_include_exclude_toggle(
|
||||
request,
|
||||
card_name=display_name,
|
||||
action=list_key,
|
||||
enabled=enabled_flag,
|
||||
include_count=len(includes),
|
||||
exclude_count=len(excludes),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trigger_payload = {
|
||||
"card": display_name,
|
||||
"list": list_key,
|
||||
"enabled": enabled_flag,
|
||||
"include_count": len(includes),
|
||||
"exclude_count": len(excludes),
|
||||
}
|
||||
try:
|
||||
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
# Alternatives cache moved to services/alts_utils
|
||||
|
||||
|
||||
|
|
@ -1132,6 +1331,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
|
|
@ -1531,6 +1731,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(suggested),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -1554,6 +1755,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(commander),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -1657,6 +1859,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(primary_commander_name),
|
||||
"tag_slot_html": tag_slot_html,
|
||||
|
|
@ -1794,6 +1997,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
"tag_slot_html": None,
|
||||
|
|
@ -2245,6 +2449,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -2884,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
base = step5_empty_ctx(request, sess)
|
||||
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.post("/step5/continue", response_class=HTMLResponse)
|
||||
|
|
@ -2954,6 +3160,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
stage_label = res.get("label")
|
||||
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
|
||||
|
|
@ -2968,6 +3175,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx2)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.post("/step5/rerun", response_class=HTMLResponse)
|
||||
|
|
@ -3006,6 +3214,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
sess["last_step"] = 5
|
||||
# Build locked cards list with ownership and in-deck presence
|
||||
|
|
@ -3032,6 +3241,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
ctx3["locked_cards"] = locked_cards
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx3)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -3073,6 +3283,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
except Exception as e:
|
||||
# Surface a friendly error on the step 5 screen with normalized context
|
||||
|
|
@ -3086,6 +3297,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
err_ctx["commander"] = commander
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
@router.get("/step5/start", response_class=HTMLResponse)
|
||||
|
|
@ -3151,8 +3363,61 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
|
|||
})
|
||||
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step5/summary", response_class=HTMLResponse)
|
||||
async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
|
||||
if not sid:
|
||||
sid = new_sid()
|
||||
sess = get_session(sid)
|
||||
|
||||
try:
|
||||
session_token = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
session_token = 0
|
||||
try:
|
||||
requested_token = int(token)
|
||||
except Exception:
|
||||
requested_token = 0
|
||||
ready = bool(sess.get("step5_summary_ready"))
|
||||
summary_data = sess.get("step5_summary") if ready else None
|
||||
if summary_data is None and ready:
|
||||
summary_data = _current_builder_summary(sess)
|
||||
if summary_data is not None:
|
||||
try:
|
||||
sess["step5_summary"] = summary_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
synergies: list[str] = []
|
||||
try:
|
||||
raw_synergies = sess.get("step5_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
except Exception:
|
||||
synergies = []
|
||||
|
||||
active_token = session_token if session_token >= requested_token else requested_token
|
||||
|
||||
if not ready or summary_data is None:
|
||||
message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
|
||||
placeholder = _step5_summary_placeholder_html(active_token, message=message)
|
||||
response = HTMLResponse(placeholder)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
ctx = step5_base_ctx(request, sess)
|
||||
ctx["summary"] = summary_data
|
||||
ctx["synergies"] = synergies
|
||||
ctx["summary_ready"] = True
|
||||
ctx["summary_token"] = active_token
|
||||
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
||||
|
||||
@router.post("/lock")
|
||||
|
|
@ -4244,6 +4509,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
# Ensure we have a CSV base stem for consistent re-exports
|
||||
base_stem = None
|
||||
|
|
@ -4311,6 +4577,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
|
||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
# Reload compliance JSON and summary
|
||||
compliance = None
|
||||
|
|
@ -4358,6 +4625,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
|
|||
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
|
||||
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
@ -4387,9 +4655,14 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
|
|||
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
ctx2 = {"request": request, "compliance": comp}
|
||||
try:
|
||||
summary_token = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
summary_token = 0
|
||||
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
|
||||
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||
return resp
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from math import ceil
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
from typing import Dict, Iterable, Mapping, Sequence, Tuple
|
||||
from urllib.parse import urlencode
|
||||
import re
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ from fastapi import APIRouter, Query, Request
|
|||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..app import templates
|
||||
from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog
|
||||
from ..services.commander_catalog_loader import CommanderCatalog, CommanderRecord, load_commander_catalog
|
||||
from ..services.theme_catalog_loader import load_index, slugify
|
||||
from ..services.telemetry import log_commander_page_view
|
||||
|
||||
|
|
@ -89,6 +90,20 @@ class ThemeRecommendation:
|
|||
score: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommanderFilterCacheEntry:
|
||||
records: Tuple[CommanderRecord, ...]
|
||||
theme_recommendations: Tuple[ThemeRecommendation, ...]
|
||||
page_views: Dict[int, Tuple[CommanderView, ...]]
|
||||
|
||||
|
||||
_FILTER_CACHE_MAX = 48
|
||||
_FILTER_CACHE: "OrderedDict[tuple[str, str, str, str], CommanderFilterCacheEntry]" = OrderedDict()
|
||||
_THEME_OPTIONS_CACHE: Dict[str, Tuple[str, ...]] = {}
|
||||
_COLOR_OPTIONS_CACHE: Dict[str, Tuple[Tuple[str, str], ...]] = {}
|
||||
_LAST_SEEN_ETAG: str | None = None
|
||||
|
||||
|
||||
def _is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
|
|
@ -142,6 +157,74 @@ def _color_label_from_code(code: str) -> str:
|
|||
return f"{pretty} ({code})"
|
||||
|
||||
|
||||
def _cache_key_for_filters(etag: str, query: str | None, theme_query: str | None, color: str | None) -> tuple[str, str, str, str]:
|
||||
def _normalize(text: str | None) -> str:
|
||||
return (text or "").strip().lower()
|
||||
|
||||
return (
|
||||
etag,
|
||||
_normalize(query),
|
||||
_normalize(theme_query),
|
||||
(color or "").strip().upper(),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_catalog_caches(etag: str) -> None:
|
||||
global _LAST_SEEN_ETAG
|
||||
if _LAST_SEEN_ETAG == etag:
|
||||
return
|
||||
_LAST_SEEN_ETAG = etag
|
||||
_FILTER_CACHE.clear()
|
||||
_THEME_OPTIONS_CACHE.clear()
|
||||
_COLOR_OPTIONS_CACHE.clear()
|
||||
|
||||
|
||||
def _theme_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[str, ...]:
|
||||
cached = _THEME_OPTIONS_CACHE.get(etag)
|
||||
if cached is not None:
|
||||
return cached
|
||||
options = _collect_theme_names(entries)
|
||||
result = tuple(options)
|
||||
_THEME_OPTIONS_CACHE[etag] = result
|
||||
return result
|
||||
|
||||
|
||||
def _color_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[Tuple[str, str], ...]:
|
||||
cached = _COLOR_OPTIONS_CACHE.get(etag)
|
||||
if cached is not None:
|
||||
return cached
|
||||
options = tuple(_build_color_options(entries))
|
||||
_COLOR_OPTIONS_CACHE[etag] = options
|
||||
return options
|
||||
|
||||
|
||||
def _get_cached_filter_entry(
|
||||
catalog: CommanderCatalog,
|
||||
query: str | None,
|
||||
theme_query: str | None,
|
||||
canon_color: str | None,
|
||||
theme_options: Sequence[str],
|
||||
) -> CommanderFilterCacheEntry:
|
||||
key = _cache_key_for_filters(catalog.etag, query, theme_query, canon_color)
|
||||
cached = _FILTER_CACHE.get(key)
|
||||
if cached is not None:
|
||||
_FILTER_CACHE.move_to_end(key)
|
||||
return cached
|
||||
|
||||
filtered = tuple(_filter_commanders(catalog.entries, query, canon_color, theme_query))
|
||||
recommendations = tuple(_build_theme_recommendations(theme_query, theme_options))
|
||||
entry = CommanderFilterCacheEntry(
|
||||
records=filtered,
|
||||
theme_recommendations=recommendations,
|
||||
page_views={},
|
||||
)
|
||||
_FILTER_CACHE[key] = entry
|
||||
_FILTER_CACHE.move_to_end(key)
|
||||
if len(_FILTER_CACHE) > _FILTER_CACHE_MAX:
|
||||
_FILTER_CACHE.popitem(last=False)
|
||||
return entry
|
||||
|
||||
|
||||
def _color_aria_label(record: CommanderRecord) -> str:
|
||||
if record.color_identity:
|
||||
names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity]
|
||||
|
|
@ -351,13 +434,19 @@ def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[
|
|||
return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT])
|
||||
|
||||
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]:
|
||||
items = list(records)
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> Sequence[CommanderRecord]:
|
||||
items: Sequence[CommanderRecord]
|
||||
if isinstance(records, Sequence):
|
||||
items = records
|
||||
else:
|
||||
items = tuple(records)
|
||||
|
||||
color_code = _canon_color_code(color)
|
||||
if color_code:
|
||||
items = [rec for rec in items if _record_color_code(rec) == color_code]
|
||||
|
||||
normalized_query = _normalize_search_text(q)
|
||||
if normalized_query:
|
||||
if normalized_query and items:
|
||||
filtered: list[tuple[float, CommanderRecord]] = []
|
||||
for rec in items:
|
||||
score = _commander_name_match_score(normalized_query, rec)
|
||||
|
|
@ -368,6 +457,7 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
|
|||
items = [rec for _, rec in filtered]
|
||||
else:
|
||||
items = []
|
||||
|
||||
normalized_theme_query = _normalize_search_text(theme)
|
||||
if normalized_theme_query and items:
|
||||
theme_tokens = tuple(normalized_theme_query.split())
|
||||
|
|
@ -381,7 +471,10 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
|
|||
items = [rec for _, rec in filtered_by_theme]
|
||||
else:
|
||||
items = []
|
||||
return items
|
||||
|
||||
if isinstance(items, list):
|
||||
return items
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]:
|
||||
|
|
@ -441,27 +534,69 @@ async def commanders_index(
|
|||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
catalog: CommanderCatalog | None = None
|
||||
entries: Sequence[CommanderRecord] = ()
|
||||
error: str | None = None
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
entries = catalog.entries
|
||||
_ensure_catalog_caches(catalog.etag)
|
||||
except FileNotFoundError:
|
||||
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
|
||||
theme_names = _collect_theme_names(entries)
|
||||
except Exception:
|
||||
error = "Commander catalog failed to load. Check server logs."
|
||||
|
||||
theme_query = (theme or "").strip()
|
||||
filtered = _filter_commanders(entries, q, color, theme_query)
|
||||
theme_recommendations = _build_theme_recommendations(theme_query, theme_names)
|
||||
total_filtered = len(filtered)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
page = page_count
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
end_index = start_index + PAGE_SIZE
|
||||
page_records = filtered[start_index:end_index]
|
||||
theme_info = _build_theme_info(page_records)
|
||||
views = [_record_to_view(rec, theme_info) for rec in page_records]
|
||||
color_options = _build_color_options(entries) if entries else []
|
||||
query_value = (q or "").strip()
|
||||
canon_color = _canon_color_code(color)
|
||||
|
||||
theme_names: Tuple[str, ...] = ()
|
||||
color_options: Tuple[Tuple[str, str], ...] | list[Tuple[str, str]] = ()
|
||||
filter_entry: CommanderFilterCacheEntry | None = None
|
||||
total_filtered = 0
|
||||
page_count = 1
|
||||
page_records: Sequence[CommanderRecord] = ()
|
||||
views: Tuple[CommanderView, ...] = ()
|
||||
theme_recommendations: Tuple[ThemeRecommendation, ...] = ()
|
||||
|
||||
if catalog is not None:
|
||||
theme_names = _theme_options_for_catalog(entries, etag=catalog.etag)
|
||||
color_options = _color_options_for_catalog(entries, etag=catalog.etag)
|
||||
filter_entry = _get_cached_filter_entry(
|
||||
catalog,
|
||||
query_value,
|
||||
theme_query,
|
||||
canon_color,
|
||||
theme_names,
|
||||
)
|
||||
total_filtered = len(filter_entry.records)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
page = page_count
|
||||
if page < 1:
|
||||
page = 1
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
end_index = start_index + PAGE_SIZE
|
||||
page_records = filter_entry.records[start_index:end_index]
|
||||
cached_views = filter_entry.page_views.get(page) if filter_entry else None
|
||||
if cached_views is None:
|
||||
theme_info = _build_theme_info(page_records)
|
||||
computed_views = tuple(_record_to_view(rec, theme_info) for rec in page_records)
|
||||
if filter_entry is not None:
|
||||
filter_entry.page_views[page] = computed_views
|
||||
if len(filter_entry.page_views) > 6:
|
||||
oldest_key = next(iter(filter_entry.page_views))
|
||||
if oldest_key != page:
|
||||
filter_entry.page_views.pop(oldest_key, None)
|
||||
views = computed_views
|
||||
else:
|
||||
views = cached_views
|
||||
theme_recommendations = filter_entry.theme_recommendations
|
||||
else:
|
||||
page = 1
|
||||
start_index = 0
|
||||
end_index = 0
|
||||
|
||||
page_start = start_index + 1 if total_filtered else 0
|
||||
page_end = start_index + len(page_records)
|
||||
has_prev = page > 1
|
||||
|
|
@ -494,12 +629,12 @@ async def commanders_index(
|
|||
context = {
|
||||
"request": request,
|
||||
"commanders": views,
|
||||
"query": q or "",
|
||||
"theme_query": theme_query,
|
||||
"query": query_value,
|
||||
"theme_query": theme_query,
|
||||
"color": canon_color,
|
||||
"color_options": color_options,
|
||||
"theme_options": theme_names,
|
||||
"theme_recommendations": theme_recommendations,
|
||||
"color_options": list(color_options) if color_options else [],
|
||||
"theme_options": theme_names,
|
||||
"theme_recommendations": theme_recommendations,
|
||||
"total_count": len(entries),
|
||||
"result_count": len(views),
|
||||
"result_total": total_filtered,
|
||||
|
|
@ -540,3 +675,23 @@ async def commanders_index_alias(
|
|||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
return await commanders_index(request, q=q, theme=theme, color=color, page=page)
|
||||
|
||||
|
||||
def prewarm_default_page() -> None:
|
||||
"""Prime the commander catalog caches for the default (no-filter) view."""
|
||||
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
_ensure_catalog_caches(catalog.etag)
|
||||
theme_options = _theme_options_for_catalog(catalog.entries, etag=catalog.etag)
|
||||
entry = _get_cached_filter_entry(catalog, "", "", "", theme_options)
|
||||
if 1 not in entry.page_views:
|
||||
page_records = entry.records[:PAGE_SIZE]
|
||||
theme_info = _build_theme_info(page_records)
|
||||
entry.page_views[1] = tuple(_record_to_view(rec, theme_info) for rec in page_records)
|
||||
except Exception:
|
||||
return
|
||||
|
|
|
|||
21
code/web/routes/telemetry.py
Normal file
21
code/web/routes/telemetry.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..services.telemetry import log_frontend_event
|
||||
|
||||
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
|
||||
|
||||
|
||||
class TelemetryEvent(BaseModel):
|
||||
event: str = Field(..., min_length=1)
|
||||
data: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/events", status_code=204)
|
||||
async def ingest_event(payload: TelemetryEvent, request: Request) -> Response:
|
||||
log_frontend_event(request, event=payload.event, data=payload.data or {})
|
||||
return Response(status_code=204)
|
||||
|
|
@ -5,7 +5,48 @@ from fastapi import Request
|
|||
from ..services import owned_store
|
||||
from . import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
from .. import app as app_module
|
||||
|
||||
|
||||
_TRUE_SET = {"1", "true", "yes", "on", "y", "t"}
|
||||
_FALSE_SET = {"0", "false", "no", "off", "n", "f"}
|
||||
|
||||
|
||||
def _coerce_bool(value: object, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
token = value.strip().lower()
|
||||
if not token:
|
||||
return default
|
||||
if token in _TRUE_SET:
|
||||
return True
|
||||
if token in _FALSE_SET:
|
||||
return False
|
||||
try:
|
||||
return bool(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _app_bool(name: str, default: bool = False) -> bool:
|
||||
import os
|
||||
import sys
|
||||
|
||||
env_val = os.getenv(name)
|
||||
if env_val is not None:
|
||||
return _coerce_bool(env_val, default)
|
||||
|
||||
app_module = sys.modules.get("code.web.app")
|
||||
if app_module is not None:
|
||||
try:
|
||||
if hasattr(app_module, name):
|
||||
return _coerce_bool(getattr(app_module, name), default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
|
||||
|
|
@ -14,6 +55,8 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode,
|
||||
combo preferences, and static game_changers. Caller can layer run-specific results.
|
||||
"""
|
||||
include_cards = list(sess.get("include_cards", []) or [])
|
||||
exclude_cards = list(sess.get("exclude_cards", []) or [])
|
||||
ctx: Dict[str, Any] = {
|
||||
"request": request,
|
||||
"commander": sess.get("commander"),
|
||||
|
|
@ -22,25 +65,49 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"partner_enabled": bool(sess.get("partner_enabled") and app_module.ENABLE_PARTNER_MECHANICS),
|
||||
"partner_enabled": bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True),
|
||||
"secondary_commander": sess.get("secondary_commander"),
|
||||
"background": sess.get("background"),
|
||||
"partner_mode": sess.get("partner_mode"),
|
||||
"partner_warnings": list(sess.get("partner_warnings", []) or []),
|
||||
"combined_commander": sess.get("combined_commander"),
|
||||
"partner_auto_note": sess.get("partner_auto_note"),
|
||||
"owned_set": owned_set(),
|
||||
"owned_set": owned_set(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
"allow_must_haves": _app_bool("ALLOW_MUST_HAVES", True),
|
||||
"show_must_have_buttons": _app_bool("SHOW_MUST_HAVE_BUTTONS", False),
|
||||
"include_cards": include_cards,
|
||||
"exclude_cards": exclude_cards,
|
||||
}
|
||||
if include_name:
|
||||
ctx["name"] = sess.get("custom_export_base")
|
||||
if include_locks:
|
||||
ctx["locks"] = list(sess.get("locks", []))
|
||||
try:
|
||||
ctx["summary_token"] = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
ctx["summary_token"] = 0
|
||||
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
||||
try:
|
||||
raw_synergies = sess.get("step5_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
ctx["synergies"] = [str(s) for s in raw_synergies if str(s).strip()]
|
||||
else:
|
||||
ctx["synergies"] = []
|
||||
except Exception:
|
||||
ctx["synergies"] = []
|
||||
ctx["must_have_state"] = {
|
||||
"includes": include_cards,
|
||||
"excludes": exclude_cards,
|
||||
"enforcement_mode": (sess.get("enforcement_mode") or "warn"),
|
||||
"allow_illegal": bool(sess.get("allow_illegal")),
|
||||
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
|
|
@ -77,7 +144,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names_list = owned_names() if (use_owned or prefer) else None
|
||||
partner_enabled = bool(sess.get("partner_enabled")) and app_module.ENABLE_PARTNER_MECHANICS
|
||||
partner_enabled = bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True)
|
||||
secondary_commander = sess.get("secondary_commander") if partner_enabled else None
|
||||
background_choice = sess.get("background") if partner_enabled else None
|
||||
ctx = orch.start_build_ctx(
|
||||
|
|
@ -311,18 +378,48 @@ def step5_ctx_from_result(
|
|||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
done = bool(res.get("done"))
|
||||
include_lower = {str(name).strip().lower(): str(name) for name in (sess.get("include_cards") or []) if str(name).strip()}
|
||||
exclude_lower = {str(name).strip().lower(): str(name) for name in (sess.get("exclude_cards") or []) if str(name).strip()}
|
||||
|
||||
raw_added = list(res.get("added_cards", []) or [])
|
||||
normalized_added: list[dict[str, Any]] = []
|
||||
for item in raw_added:
|
||||
if isinstance(item, dict):
|
||||
entry: dict[str, Any] = dict(item)
|
||||
else:
|
||||
entry = {}
|
||||
try:
|
||||
entry.update(vars(item)) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
pass
|
||||
# Preserve common attributes when vars() empty
|
||||
for attr in ("name", "role", "sub_role", "tags", "tags_slug", "reason", "count"):
|
||||
if attr not in entry and hasattr(item, attr):
|
||||
try:
|
||||
entry[attr] = getattr(item, attr)
|
||||
except Exception:
|
||||
continue
|
||||
name_val = str(entry.get("name") or "").strip()
|
||||
key = name_val.lower()
|
||||
entry["name"] = name_val
|
||||
entry["must_include"] = key in include_lower
|
||||
entry["must_exclude"] = key in exclude_lower
|
||||
entry["must_include_label"] = include_lower.get(key)
|
||||
entry["must_exclude_label"] = exclude_lower.get(key)
|
||||
normalized_added.append(entry)
|
||||
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": status_text,
|
||||
"stage_label": res.get("label"),
|
||||
"log": res.get("log_delta", ""),
|
||||
"added_cards": res.get("added_cards", []),
|
||||
"added_cards": normalized_added,
|
||||
"i": res.get("idx"),
|
||||
"n": res.get("total"),
|
||||
"csv_path": res.get("csv_path") if done else None,
|
||||
"txt_path": res.get("txt_path") if done else None,
|
||||
"summary": res.get("summary") if done else None,
|
||||
"compliance": res.get("compliance") if done else None,
|
||||
"compliance": res.get("compliance") if done else None,
|
||||
"show_skipped": bool(show_skipped),
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
|
|
@ -330,7 +427,7 @@ def step5_ctx_from_result(
|
|||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
"gated": bool(res.get("gated")),
|
||||
"gated": bool(res.get("gated")),
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
|
|
@ -344,6 +441,57 @@ def step5_ctx_from_result(
|
|||
ctx.update(hover_meta)
|
||||
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
|
||||
ctx["commander_display_name"] = ctx.get("commander")
|
||||
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0))
|
||||
except Exception:
|
||||
token_val = 0
|
||||
summary_value = ctx.get("summary")
|
||||
synergies_list: list[str] = []
|
||||
if summary_value is not None:
|
||||
try:
|
||||
sess["step5_summary"] = summary_value
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(summary_value, dict):
|
||||
raw_synergies = summary_value.get("synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
else:
|
||||
meta = summary_value.get("meta") if isinstance(summary_value.get("meta"), dict) else {}
|
||||
if isinstance(meta, dict):
|
||||
raw_synergies = meta.get("synergies") or meta.get("commander_synergies")
|
||||
if isinstance(raw_synergies, (list, tuple, set)):
|
||||
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
|
||||
token_val += 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = True
|
||||
if synergies_list:
|
||||
sess["step5_synergies"] = synergies_list
|
||||
else:
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
token_val += 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
synergies_list = []
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
|
||||
ctx["synergies"] = synergies_list
|
||||
return ctx
|
||||
|
||||
|
||||
|
|
@ -379,6 +527,25 @@ def step5_error_ctx(
|
|||
"added_total": 0,
|
||||
"skipped": False,
|
||||
}
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0)) + 1
|
||||
except Exception:
|
||||
token_val = 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = False
|
||||
ctx["synergies"] = []
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
|
@ -410,6 +577,25 @@ def step5_empty_ctx(
|
|||
"show_skipped": False,
|
||||
"skipped": False,
|
||||
}
|
||||
try:
|
||||
token_val = int(sess.get("step5_summary_token", 0)) + 1
|
||||
except Exception:
|
||||
token_val = 1
|
||||
sess["step5_summary_token"] = token_val
|
||||
sess["step5_summary_ready"] = False
|
||||
try:
|
||||
if "step5_summary" in sess:
|
||||
del sess["step5_summary"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "step5_synergies" in sess:
|
||||
del sess["step5_synergies"]
|
||||
except Exception:
|
||||
pass
|
||||
ctx["summary_token"] = token_val
|
||||
ctx["summary_ready"] = False
|
||||
ctx["synergies"] = []
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
|
|
|||
|
|
@ -2,10 +2,23 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from deck_builder import builder_constants as bc
|
||||
from .build_utils import owned_set as owned_set_helper
|
||||
from .combo_utils import detect_for_summary as _detect_for_summary
|
||||
|
||||
|
||||
def _owned_set_helper() -> set[str]:
|
||||
try:
|
||||
from .build_utils import owned_set as _owned_set # type: ignore
|
||||
|
||||
return _owned_set()
|
||||
except Exception:
|
||||
try:
|
||||
from . import owned_store
|
||||
|
||||
return {str(n).strip().lower() for n in owned_store.get_names()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _sanitize_tag_list(values: Iterable[Any]) -> List[str]:
|
||||
cleaned: List[str] = []
|
||||
for raw in values or []: # type: ignore[arg-type]
|
||||
|
|
@ -148,7 +161,7 @@ def summary_ctx(
|
|||
synergy_tags.append(label)
|
||||
versions = det.get("versions", {} if include_versions else None)
|
||||
return {
|
||||
"owned_set": owned_set_helper(),
|
||||
"owned_set": _owned_set_helper(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"combos": combos,
|
||||
"synergies": synergy_tags,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@ __all__ = [
|
|||
"log_commander_create_deck",
|
||||
"log_partner_suggestions_generated",
|
||||
"log_partner_suggestion_selected",
|
||||
"log_include_exclude_toggle",
|
||||
"log_frontend_event",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger("web.commander_browser")
|
||||
_PARTNER_LOGGER = logging.getLogger("web.partner_suggestions")
|
||||
_MUST_HAVE_LOGGER = logging.getLogger("web.must_haves")
|
||||
_FRONTEND_LOGGER = logging.getLogger("web.frontend_events")
|
||||
|
||||
|
||||
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
|
||||
|
|
@ -217,3 +221,45 @@ def log_partner_suggestion_selected(
|
|||
if warnings:
|
||||
payload["warnings"] = list(warnings)
|
||||
_emit(_PARTNER_LOGGER, payload)
|
||||
|
||||
|
||||
def log_include_exclude_toggle(
|
||||
request: Request,
|
||||
*,
|
||||
card_name: str,
|
||||
action: str,
|
||||
enabled: bool,
|
||||
include_count: int,
|
||||
exclude_count: int,
|
||||
) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"event": "must_haves.toggle",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"card": card_name,
|
||||
"list": action,
|
||||
"enabled": bool(enabled),
|
||||
"include_count": int(include_count),
|
||||
"exclude_count": int(exclude_count),
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_MUST_HAVE_LOGGER, payload)
|
||||
|
||||
|
||||
def log_frontend_event(
|
||||
request: Request,
|
||||
event: str,
|
||||
data: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
snapshot: Dict[str, Any] = {}
|
||||
if isinstance(data, Mapping):
|
||||
snapshot = {str(k): data[k] for k in data}
|
||||
payload: Dict[str, Any] = {
|
||||
"event": f"frontend.{event}",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"data": snapshot,
|
||||
"referer": request.headers.get("referer"),
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_FRONTEND_LOGGER, payload)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -235,7 +235,11 @@ small, .muted{ color: var(--muted); }
|
|||
|
||||
/* Skeletons */
|
||||
[data-skeleton]{ position: relative; }
|
||||
[data-skeleton].is-loading > *{ opacity: 0; }
|
||||
[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; }
|
||||
[data-skeleton-placeholder]{ display:none; pointer-events:none; }
|
||||
[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; }
|
||||
[data-skeleton][data-skeleton-overlay="false"]::after,
|
||||
[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; }
|
||||
[data-skeleton]::after{
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
|
|
@ -246,6 +250,23 @@ small, .muted{ color: var(--muted); }
|
|||
display: none;
|
||||
}
|
||||
[data-skeleton].is-loading::after{ display:block; }
|
||||
[data-skeleton].is-loading::before{
|
||||
content: attr(data-skeleton-label);
|
||||
position:absolute;
|
||||
top:50%;
|
||||
left:50%;
|
||||
transform:translate(-50%, -50%);
|
||||
color: var(--muted);
|
||||
font-size:.85rem;
|
||||
text-align:center;
|
||||
line-height:1.4;
|
||||
max-width:min(92%, 360px);
|
||||
padding:.3rem .5rem;
|
||||
pointer-events:none;
|
||||
z-index:1;
|
||||
filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
|
||||
}
|
||||
[data-skeleton][data-skeleton-label=""]::before{ content:''; }
|
||||
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
|
||||
|
||||
/* Banner */
|
||||
|
|
@ -268,6 +289,9 @@ small, .muted{ color: var(--muted); }
|
|||
justify-content: start; /* pack as many as possible per row */
|
||||
/* Prevent scroll chaining bounce that can cause flicker near bottom */
|
||||
overscroll-behavior: contain;
|
||||
content-visibility: auto;
|
||||
contain: layout paint;
|
||||
contain-intrinsic-size: 640px 420px;
|
||||
}
|
||||
@media (max-width: 420px){
|
||||
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
|
@ -289,10 +313,66 @@ small, .muted{ color: var(--muted); }
|
|||
border-color: #f5e6a8; /* soft parchment gold */
|
||||
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
|
||||
}
|
||||
.card-tile.must-include{
|
||||
border-color: rgba(74,222,128,.85);
|
||||
box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
|
||||
}
|
||||
.card-tile.must-exclude{
|
||||
border-color: rgba(239,68,68,.85);
|
||||
box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
|
||||
opacity: .95;
|
||||
}
|
||||
.card-tile.must-include.must-exclude{
|
||||
border-color: rgba(249,115,22,.85);
|
||||
box-shadow: 0 0 0 1px rgba(249,115,22,.4) 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; }
|
||||
|
||||
.must-have-controls{
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
gap:.35rem;
|
||||
flex-wrap:wrap;
|
||||
margin-top:.35rem;
|
||||
}
|
||||
.must-have-btn{
|
||||
border:1px solid var(--border);
|
||||
background:rgba(30,41,59,.6);
|
||||
color:#f8fafc;
|
||||
font-size:11px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.06em;
|
||||
padding:.25rem .6rem;
|
||||
border-radius:9999px;
|
||||
cursor:pointer;
|
||||
transition: all .18s ease;
|
||||
}
|
||||
.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{
|
||||
border-color: rgba(74,222,128,.75);
|
||||
background: rgba(74,222,128,.18);
|
||||
color: #bbf7d0;
|
||||
box-shadow: 0 0 0 1px rgba(16,185,129,.25);
|
||||
}
|
||||
.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{
|
||||
border-color: rgba(239,68,68,.75);
|
||||
background: rgba(239,68,68,.18);
|
||||
color: #fecaca;
|
||||
box-shadow: 0 0 0 1px rgba(239,68,68,.25);
|
||||
}
|
||||
.must-have-btn:focus-visible{
|
||||
outline:2px solid rgba(59,130,246,.6);
|
||||
outline-offset:2px;
|
||||
}
|
||||
.card-tile.must-exclude .must-have-btn.include[data-active="0"],
|
||||
.card-tile.must-include .must-have-btn.exclude[data-active="0"]{
|
||||
opacity:.65;
|
||||
}
|
||||
|
||||
.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
|
||||
.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
|
||||
|
||||
/* Shared ownership badge for card tiles and stacked images */
|
||||
.owned-badge{
|
||||
position:absolute;
|
||||
|
|
@ -547,3 +627,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
@media (min-width: 900px) {
|
||||
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
|
||||
}
|
||||
|
||||
/* Analytics accordion styling */
|
||||
.analytics-accordion {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.analytics-accordion summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.analytics-accordion summary:hover {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.analytics-accordion summary:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.analytics-accordion[open] summary {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.analytics-accordion .analytics-content {
|
||||
animation: accordion-slide-down 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-placeholder .skeleton-pulse {
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@
|
|||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
window.__telemetryEndpoint = '/telemetry/events';
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@
|
|||
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
|
||||
]
|
||||
#}
|
||||
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
|
||||
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;" data-skeleton data-skeleton-label="Pulling alternatives…" data-skeleton-delay="450">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem; gap:.5rem; flex-wrap:wrap;">
|
||||
<strong>Alternatives</strong>
|
||||
{% set toggle_q = '0' if require_owned else '1' %}
|
||||
{% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
|
||||
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ toggle_q }}"
|
||||
hx-target="closest .alts" hx-swap="outerHTML">{{ toggle_label }}</button>
|
||||
hx-target="closest .alts" hx-swap="outerHTML"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ name|lower }}:owned:{{ toggle_q }}" data-hx-cache-ttl="20000">{{ toggle_label }}</button>
|
||||
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ 1 if require_owned else 0 }}&refresh=1"
|
||||
hx-target="closest .alts" hx-swap="outerHTML" title="Request a fresh pool of alternatives">New pool</button>
|
||||
</div>
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
{% if not items or items|length == 0 %}
|
||||
<div class="muted">No alternatives found{{ ' (owned only)' if require_owned else '' }}.</div>
|
||||
{% else %}
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
<ul class="alt-list">
|
||||
{% for it in items %}
|
||||
{% set badge = '✔' if it.owned else '✖' %}
|
||||
{% set title = 'Owned' if it.owned else 'Not owned' %}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
{# Flagged tiles by category, in the same card grid style #}
|
||||
{% if flagged_meta and flagged_meta|length > 0 %}
|
||||
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
|
||||
<div class="card-grid">
|
||||
<div class="card-grid"{% if flagged_meta|length >= 12 %} data-virtualize="grid" data-virtualize-min="12" data-virtualize-columns="4"{% endif %}>
|
||||
{% for f in flagged_meta %}
|
||||
{% set sev = (f.severity or 'FAIL')|upper %}
|
||||
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
|
||||
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
|
||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
||||
data-hx-cache="1" data-hx-cache-key="newdeck:inspect:{{ cand.display|lower }}" data-hx-cache-ttl="45000"
|
||||
data-hx-prefetch="/build/new/inspect?name={{ cand.display|urlencode }}"
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
|
||||
{{ cand.display }}
|
||||
{% if cand.warning %}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
<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" />
|
||||
hx-get="/build/new/candidates" hx-trigger="debouncedinput change" hx-target="#newdeck-candidates" hx-sync="this:replace"
|
||||
data-hx-debounce="220" data-hx-debounce-events="input"
|
||||
data-hx-debounce-flush="blur" />
|
||||
</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>
|
||||
|
|
@ -204,6 +206,11 @@
|
|||
Enter one card name per line. Cards are validated against the database with smart name matching.
|
||||
</small>
|
||||
</fieldset>
|
||||
{% if not show_must_have_buttons %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.75rem;">
|
||||
Step 5 quick-add buttons are hidden (<code>SHOW_MUST_HAVE_BUTTONS=0</code>), but you can still seed must include/exclude lists here.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Advanced options (ideals)</summary>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{% 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">
|
||||
<nav class="stage-nav" aria-label="Build stages" data-skeleton data-skeleton-label="Refreshing stages…" data-skeleton-delay="400">
|
||||
<ol>
|
||||
{% for idx in range(1, total+1) %}
|
||||
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}
|
||||
|
|
|
|||
|
|
@ -356,8 +356,9 @@
|
|||
{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<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"
|
||||
|
|
@ -369,15 +370,25 @@
|
|||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
{% if allow_must_haves and show_must_have_buttons %}
|
||||
<div class="must-have-controls" role="group" aria-label="Must have controls">
|
||||
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
|
||||
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">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>
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
|
|
@ -391,8 +402,9 @@
|
|||
{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<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"
|
||||
|
|
@ -404,15 +416,25 @@
|
|||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
{% if allow_must_haves and show_must_have_buttons %}
|
||||
<div class="must-have-controls" role="group" aria-label="Must have controls">
|
||||
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
|
||||
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">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>
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
|
|
@ -420,7 +442,11 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if allow_must_haves and show_must_have_buttons %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. “Must include” will try to pull the card back in on future reruns, while “Must exclude” blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
|
||||
{% endif %}
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||
No cards match your filters.
|
||||
</div>
|
||||
|
|
@ -435,12 +461,17 @@
|
|||
|
||||
<!-- controls now above -->
|
||||
|
||||
{% if status and status.startswith('Build complete') and summary %}
|
||||
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% if allow_must_haves %}
|
||||
{% include "partials/include_exclude_summary.html" with oob=False %}
|
||||
{% endif %}
|
||||
<div id="deck-summary" data-summary
|
||||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||||
hx-trigger="load, step5:refresh from:body"
|
||||
hx-swap="outerHTML">
|
||||
<div class="muted" style="margin-top:1rem;">
|
||||
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<a href="{{ return_url }}" class="btn" style="margin-left:auto;">← Back to Commanders</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="wizard">
|
||||
<div id="wizard" data-skeleton data-skeleton-label="Preparing build surfaces…" data-skeleton-delay="420" aria-live="polite">
|
||||
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,30 +45,45 @@
|
|||
<button type="submit" class="btn filter-submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div id="commander-loading" class="commander-loading" role="status" aria-live="polite">
|
||||
<span class="sr-only">Loading commanders…</span>
|
||||
<div class="commander-skeleton-list" aria-hidden="true">
|
||||
{% for i in range(3) %}
|
||||
<article class="commander-skeleton">
|
||||
<div class="skeleton-thumb shimmer"></div>
|
||||
<div class="skeleton-main">
|
||||
<div class="skeleton-line skeleton-title shimmer"></div>
|
||||
<div class="skeleton-line skeleton-meta shimmer"></div>
|
||||
<div class="skeleton-chip-row">
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<div
|
||||
id="commander-results"
|
||||
data-skeleton
|
||||
data-skeleton-label=""
|
||||
data-skeleton-delay="420"
|
||||
data-skeleton-overlay="false"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
id="commander-loading"
|
||||
class="commander-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-skeleton-placeholder
|
||||
>
|
||||
<span class="sr-only">Loading commanders…</span>
|
||||
<div class="commander-skeleton-list" aria-hidden="true">
|
||||
{% for i in range(3) %}
|
||||
<article class="commander-skeleton">
|
||||
<div class="skeleton-thumb shimmer"></div>
|
||||
<div class="skeleton-main">
|
||||
<div class="skeleton-line skeleton-title shimmer"></div>
|
||||
<div class="skeleton-line skeleton-meta shimmer"></div>
|
||||
<div class="skeleton-chip-row">
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
</div>
|
||||
<div class="skeleton-line skeleton-text shimmer"></div>
|
||||
</div>
|
||||
<div class="skeleton-line skeleton-text shimmer"></div>
|
||||
</div>
|
||||
<div class="skeleton-cta shimmer"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
<div class="skeleton-cta shimmer"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="commander-results">
|
||||
{% include "commanders/list_fragment.html" %}
|
||||
<div class="commander-results-swap" data-skeleton-content>
|
||||
{% include "commanders/list_fragment.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -93,6 +108,11 @@
|
|||
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; position:relative; }
|
||||
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
|
||||
.commander-thumb-img.is-placeholder { animation: commander-thumb-pulse 1.2s ease-in-out infinite alternate; }
|
||||
@keyframes commander-thumb-pulse {
|
||||
from { filter:brightness(0.45); }
|
||||
to { filter:brightness(0.65); }
|
||||
}
|
||||
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
||||
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
.commander-name { margin:0; font-size:1.25rem; }
|
||||
|
|
@ -121,8 +141,8 @@
|
|||
.theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
|
||||
.theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
|
||||
.commander-loading { display:none; margin-top:1rem; }
|
||||
.commander-loading.htmx-request { display:block; }
|
||||
.commander-loading { margin-top:1rem; gap:1rem; }
|
||||
.commander-results-swap { display:flex; flex-direction:column; }
|
||||
.commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; }
|
||||
.commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.skeleton-thumb { width:160px; height:220px; border-radius:10px; }
|
||||
|
|
|
|||
|
|
@ -2,20 +2,37 @@
|
|||
{% from "partials/_macros.html" import color_identity %}
|
||||
{% set record = entry.record %}
|
||||
{% set display_label = record.name if '//' in record.name else record.display_name %}
|
||||
{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
{% set small = record.image_small_url or record.image_normal_url %}
|
||||
{% set normal = record.image_normal_url or small %}
|
||||
<img
|
||||
src="{{ small }}"
|
||||
srcset="{{ small }} 160w, {{ record.image_normal_url or small }} 488w"
|
||||
sizes="160px"
|
||||
src="{{ placeholder_pixel }}"
|
||||
alt="{{ record.display_name }} card art"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="160"
|
||||
height="223"
|
||||
data-lazy-image="commander"
|
||||
data-lazy-src="{{ small }}"
|
||||
data-lazy-srcset="{{ small }} 160w, {{ normal }} 488w"
|
||||
data-lazy-sizes="(max-width: 900px) 60vw, 160px"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-original-name="{{ record.name }}"
|
||||
data-hover-simple="true"
|
||||
class="commander-thumb-img"
|
||||
/>
|
||||
<noscript>
|
||||
<img
|
||||
src="{{ small }}"
|
||||
srcset="{{ small }} 160w, {{ normal }} 488w"
|
||||
sizes="160px"
|
||||
alt="{{ record.display_name }} card art"
|
||||
width="160"
|
||||
height="223"
|
||||
/>
|
||||
</noscript>
|
||||
</div>
|
||||
<div class="commander-main">
|
||||
<div class="commander-header">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<div id="deck-summary" data-summary>
|
||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
<section style="margin-top:.5rem;">
|
||||
|
|
@ -55,7 +56,7 @@
|
|||
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
||||
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
<div class="list-grid"{% if virtualize %} data-virtualize="list" data-virtualize-min="90"{% endif %}>
|
||||
{% for c in clist %}
|
||||
{# Compute overlaps with detected deck synergies when available #}
|
||||
{% set overlaps = [] %}
|
||||
|
|
@ -190,7 +191,13 @@
|
|||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Overview</h5>
|
||||
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<span>Mana Overview</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .5rem 0;">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 -->
|
||||
|
|
@ -203,28 +210,26 @@
|
|||
{% 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 label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% 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>
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
{% 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 label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></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>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
|
|
@ -260,22 +265,20 @@
|
|||
{% 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 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(' • ') %}
|
||||
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></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>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
|
|
@ -298,21 +301,19 @@
|
|||
{% 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 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)) %}
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></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>
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
</div>
|
||||
|
|
@ -324,10 +325,18 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<span>Test Hand</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
|
||||
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
</h5>
|
||||
|
|
@ -506,15 +515,24 @@
|
|||
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</details>
|
||||
</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); }
|
||||
.chart-tooltip { position: fixed; 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); max-width: 90vw; }
|
||||
/* Pinned tooltip gets pointer events for Copy button */
|
||||
.chart-tooltip.pinned { pointer-events: auto; border-color: #f59e0b; box-shadow: 0 4px 20px rgba(245,158,11,.3); }
|
||||
/* Unpinned tooltip has no pointer events (hover only) */
|
||||
.chart-tooltip:not(.pinned) { pointer-events: none; }
|
||||
/* 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); }
|
||||
/* Chart columns get cursor pointer */
|
||||
.chart-column svg { cursor: pointer; transition: opacity 0.15s ease; }
|
||||
.chart-column svg:hover { opacity: 0.85; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
|
|
@ -532,53 +550,72 @@
|
|||
var hoverTimer = null;
|
||||
var lastNames = [];
|
||||
var lastType = '';
|
||||
var pinnedNames = [];
|
||||
var pinnedType = '';
|
||||
var pinnedEl = null;
|
||||
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';
|
||||
var isMobile = vw < 768;
|
||||
|
||||
if (isMobile) {
|
||||
// Mobile: fixed to lower-right corner
|
||||
tip.style.right = '8px';
|
||||
tip.style.bottom = '8px';
|
||||
tip.style.left = 'auto';
|
||||
tip.style.top = 'auto';
|
||||
tip.style.maxWidth = 'calc(100vw - 16px)';
|
||||
} else {
|
||||
// Desktop: fixed to lower-left corner
|
||||
tip.style.left = '8px';
|
||||
tip.style.bottom = '8px';
|
||||
tip.style.right = 'auto';
|
||||
tip.style.top = 'auto';
|
||||
tip.style.maxWidth = '400px';
|
||||
}
|
||||
}
|
||||
function buildTip(el) {
|
||||
// Render tooltip with safe DOM and a Copy button for card list
|
||||
function buildTip(el, isPinned) {
|
||||
// Render tooltip with safe DOM
|
||||
tip.innerHTML = '';
|
||||
var t = el.getAttribute('data-type');
|
||||
var header = document.createElement('div');
|
||||
header.style.fontWeight = '600';
|
||||
header.style.marginBottom = '.25rem';
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.gap = '.5rem';
|
||||
|
||||
var titleSpan = document.createElement('span');
|
||||
var listText = '';
|
||||
if (t === 'pips') {
|
||||
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
titleSpan.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') + '%)';
|
||||
titleSpan.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') + '%)';
|
||||
titleSpan.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') || '';
|
||||
titleSpan.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);
|
||||
header.appendChild(titleSpan);
|
||||
|
||||
// Add Copy button that works with pinned tooltips
|
||||
if (listText && isPinned) {
|
||||
var btn = document.createElement('button');
|
||||
btn.textContent = 'Copy';
|
||||
btn.style.fontSize = '12px';
|
||||
btn.style.padding = '.2rem .4rem';
|
||||
btn.style.fontSize = '11px';
|
||||
btn.style.padding = '.15rem .35rem';
|
||||
btn.style.border = '1px solid var(--border)';
|
||||
btn.style.background = '#12161c';
|
||||
btn.style.color = '#e5e7eb';
|
||||
btn.style.borderRadius = '4px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.flexShrink = '0';
|
||||
btn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
try {
|
||||
|
|
@ -592,7 +629,28 @@
|
|||
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
|
||||
} catch(_) {}
|
||||
});
|
||||
tip.appendChild(btn);
|
||||
header.appendChild(btn);
|
||||
}
|
||||
|
||||
tip.appendChild(header);
|
||||
if (listText) {
|
||||
var pre = document.createElement('pre');
|
||||
pre.style.margin = '.25rem 0 0 0';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.style.fontSize = '12px';
|
||||
pre.textContent = listText;
|
||||
tip.appendChild(pre);
|
||||
}
|
||||
|
||||
// Add hint for pinning on desktop
|
||||
if (!isPinned && window.innerWidth >= 768) {
|
||||
var hint = document.createElement('div');
|
||||
hint.style.marginTop = '.35rem';
|
||||
hint.style.fontSize = '11px';
|
||||
hint.style.color = '#9ca3af';
|
||||
hint.style.fontStyle = 'italic';
|
||||
hint.textContent = 'Click to pin';
|
||||
tip.appendChild(hint);
|
||||
}
|
||||
}
|
||||
function normalizeList(list) {
|
||||
|
|
@ -605,41 +663,114 @@
|
|||
return s.trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
function unpin() {
|
||||
if (pinnedEl) {
|
||||
pinnedEl.style.outline = '';
|
||||
pinnedEl = null;
|
||||
}
|
||||
if (pinnedNames && pinnedNames.length) {
|
||||
highlightNames(pinnedNames, false);
|
||||
}
|
||||
pinnedNames = [];
|
||||
pinnedType = '';
|
||||
tip.classList.remove('pinned');
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
|
||||
function pin(el, e) {
|
||||
// Unpin previous if different element
|
||||
if (pinnedEl && pinnedEl !== el) {
|
||||
unpin();
|
||||
}
|
||||
|
||||
// Toggle: if clicking same element, unpin
|
||||
if (pinnedEl === el) {
|
||||
unpin();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pin new element
|
||||
pinnedEl = el;
|
||||
el.style.outline = '2px solid #f59e0b';
|
||||
el.style.outlineOffset = '2px';
|
||||
|
||||
var dataType = el.getAttribute('data-type');
|
||||
pinnedNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
pinnedType = dataType;
|
||||
|
||||
tip.classList.add('pinned');
|
||||
buildTip(el, true);
|
||||
position(e);
|
||||
highlightNames(pinnedNames, true);
|
||||
}
|
||||
|
||||
function attach() {
|
||||
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||
// Attach to SVG elements with data-type for better hover zones
|
||||
document.querySelectorAll('svg[data-type]').forEach(function(el) {
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
buildTip(el);
|
||||
// Don't show hover tooltip if this element is pinned
|
||||
if (pinnedEl === el) return;
|
||||
|
||||
clearHoverTimer();
|
||||
buildTip(el, false);
|
||||
position(e);
|
||||
// Cross-highlight for mana curve bars -> card items
|
||||
try {
|
||||
if (el.getAttribute('data-type') === 'curve') {
|
||||
var dataType = el.getAttribute('data-type');
|
||||
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
|
||||
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);
|
||||
lastType = dataType;
|
||||
// Only apply hover highlights if nothing is pinned
|
||||
if (!pinnedEl) {
|
||||
highlightNames(lastNames, true);
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
el.addEventListener('mousemove', position);
|
||||
|
||||
el.addEventListener('mousemove', function(e) {
|
||||
if (pinnedEl === el) return;
|
||||
position(e);
|
||||
});
|
||||
|
||||
el.addEventListener('mouseleave', function() {
|
||||
// Don't hide if pinned
|
||||
if (pinnedEl) return;
|
||||
|
||||
clearHoverTimer();
|
||||
hoverTimer = setTimeout(function(){
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
try { if (lastNames && lastNames.length && !pinnedEl) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
}, 200);
|
||||
});
|
||||
|
||||
el.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pin(el, e);
|
||||
});
|
||||
});
|
||||
// Keep tooltip open while hovering it (for pinned tooltips with Copy button)
|
||||
tip.addEventListener('mouseenter', function(){
|
||||
clearHoverTimer();
|
||||
});
|
||||
// Keep tooltip open while hovering it
|
||||
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
|
||||
tip.addEventListener('mouseleave', function(){
|
||||
// Don't hide if pinned
|
||||
if (pinnedEl) return;
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
});
|
||||
|
||||
// Click outside to unpin
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!pinnedEl) return;
|
||||
// Don't unpin if clicking the tooltip itself or a chart
|
||||
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
|
||||
unpin();
|
||||
});
|
||||
|
||||
// Initialize Show C toggle
|
||||
initShowCToggle();
|
||||
}
|
||||
|
|
@ -663,9 +794,9 @@
|
|||
}
|
||||
function highlightNames(names, on){
|
||||
if (!Array.isArray(names) || names.length === 0) return;
|
||||
// List view spans
|
||||
// List view spans - target only the .name span, not the parent .list-row
|
||||
try {
|
||||
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
|
||||
document.querySelectorAll('#typeview-list .list-row .name[data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
if (!n) return;
|
||||
var match = names.indexOf(n) !== -1;
|
||||
|
|
@ -695,4 +826,5 @@
|
|||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
||||
</script>
|
||||
</div>
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
{% set is_oob = oob if oob is defined else False %}
|
||||
<div id="include-exclude-summary" data-summary{% if is_oob %} hx-swap-oob="true"{% endif %}>
|
||||
{% set pending_state = must_have_state if must_have_state is defined else None %}
|
||||
{% set pending_includes = pending_state.includes if pending_state and pending_state.includes is not none else [] %}
|
||||
{% set pending_excludes = pending_state.excludes if pending_state and pending_state.excludes is not none else [] %}
|
||||
{% set has_pending = (pending_includes|length > 0) or (pending_excludes|length > 0) %}
|
||||
|
||||
{% if summary and summary.include_exclude_summary %}
|
||||
{% set ie_summary = summary.include_exclude_summary %}
|
||||
{% set has_data = (ie_summary.include_cards|length > 0) or (ie_summary.exclude_cards|length > 0) or (ie_summary.include_added|length > 0) or (ie_summary.excluded_removed|length > 0) %}
|
||||
|
|
@ -192,4 +199,45 @@
|
|||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% elif has_pending %}
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Must-Have Selections</h5>
|
||||
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.5rem;">These card lists will apply to the next build run.</div>
|
||||
<div style="display:flex; flex-direction:column; gap:.75rem;">
|
||||
<div>
|
||||
<div class="muted" style="font-weight:600; color:#4ade80; margin-bottom:.35rem;">✓ Must Include ({{ pending_includes|length }})</div>
|
||||
{% if pending_includes|length %}
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in pending_includes %}
|
||||
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">No include cards selected.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-weight:600; color:#ef4444; margin-bottom:.35rem;">✗ Must Exclude ({{ pending_excludes|length }})</div>
|
||||
{% if pending_excludes|length %}
|
||||
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for card in pending_excludes %}
|
||||
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">No exclude cards selected.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% if show_must_have_buttons %}
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Must-Have Selections</h5>
|
||||
<div class="muted" style="font-size:12px;">Use the ✓/✗ toggles on each card to mark must-have preferences.</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
<div id="theme-picker" class="theme-picker" hx-get="/themes/fragment/list?limit=20&offset=0" hx-trigger="load" hx-target="#theme-results" hx-swap="innerHTML" role="region" aria-label="Theme picker">
|
||||
<div class="theme-picker-controls">
|
||||
<input type="text" id="theme-search" placeholder="Search themes or synergies" aria-label="Search"
|
||||
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="keyup changed delay:250ms" name="q" />
|
||||
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="debouncedinput change" name="q"
|
||||
data-hx-debounce="260" data-hx-debounce-events="input,keyup" data-hx-debounce-flush="blur" data-hx-debounce-group="theme-search" />
|
||||
<select id="theme-archetype" name="archetype" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
|
||||
<option value="">All Archetypes</option>
|
||||
{% if archetypes %}{% for a in archetypes %}<option value="{{ a }}">{{ a }}</option>{% endfor %}{% endif %}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,17 +15,18 @@ services:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
# UI features/flags
|
||||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
|
||||
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
|
||||
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# Partner / Background mechanics (feature flag)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ services:
|
|||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
|
||||
|
|
@ -28,6 +28,7 @@ services:
|
|||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
|
||||
|
|
|
|||
212
docs/styleguide.md
Normal file
212
docs/styleguide.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# MTG Deckbuilder Web UI Style Guide
|
||||
|
||||
## Design Tokens
|
||||
|
||||
Design tokens provide a consistent foundation for all UI elements. These are defined as CSS custom properties in `code/web/static/styles.css`.
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
Use the spacing scale for margins, padding, and gaps:
|
||||
|
||||
```css
|
||||
--space-xs: 0.25rem; /* 4px - Tight spacing within components */
|
||||
--space-sm: 0.5rem; /* 8px - Default gaps between small elements */
|
||||
--space-md: 0.75rem; /* 12px - Standard component padding */
|
||||
--space-lg: 1rem; /* 16px - Section spacing, card gaps */
|
||||
--space-xl: 1.5rem; /* 24px - Major section breaks */
|
||||
--space-2xl: 2rem; /* 32px - Page-level spacing */
|
||||
```
|
||||
|
||||
**Usage examples:**
|
||||
- Chip gaps: `gap: var(--space-sm)`
|
||||
- Panel padding: `padding: var(--space-md)`
|
||||
- Section margins: `margin: var(--space-xl) 0`
|
||||
|
||||
### Typography Scale
|
||||
|
||||
Consistent font sizes for hierarchy:
|
||||
|
||||
```css
|
||||
--text-xs: 0.75rem; /* 12px - Meta info, badges */
|
||||
--text-sm: 0.875rem; /* 14px - Secondary text */
|
||||
--text-base: 1rem; /* 16px - Body text */
|
||||
--text-lg: 1.125rem; /* 18px - Subheadings */
|
||||
--text-xl: 1.25rem; /* 20px - Section headers */
|
||||
--text-2xl: 1.5rem; /* 24px - Page titles */
|
||||
```
|
||||
|
||||
**Font weights:**
|
||||
```css
|
||||
--font-normal: 400; /* Body text */
|
||||
--font-medium: 500; /* Emphasis */
|
||||
--font-semibold: 600; /* Headings */
|
||||
--font-bold: 700; /* Strong emphasis */
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
Consistent corner rounding:
|
||||
|
||||
```css
|
||||
--radius-sm: 4px; /* Subtle rounding */
|
||||
--radius-md: 6px; /* Buttons, inputs */
|
||||
--radius-lg: 8px; /* Panels, cards */
|
||||
--radius-xl: 12px; /* Large containers */
|
||||
--radius-full: 999px; /* Pills, chips */
|
||||
```
|
||||
|
||||
### Color Tokens
|
||||
|
||||
#### Semantic Colors
|
||||
```css
|
||||
--bg: #0f0f10; /* Page background */
|
||||
--panel: #1a1b1e; /* Panel/card backgrounds */
|
||||
--text: #e8e8e8; /* Primary text */
|
||||
--muted: #b6b8bd; /* Secondary text */
|
||||
--border: #2a2b2f; /* Borders and dividers */
|
||||
--ring: #60a5fa; /* Focus indicator */
|
||||
--ok: #16a34a; /* Success states */
|
||||
--warn: #f59e0b; /* Warning states */
|
||||
--err: #ef4444; /* Error states */
|
||||
```
|
||||
|
||||
#### MTG Color Identity
|
||||
```css
|
||||
--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);
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Chips
|
||||
|
||||
Chips display tags, status indicators, and metadata.
|
||||
|
||||
**Basic chip:**
|
||||
```html
|
||||
<span class="chip">
|
||||
<span class="dot" style="background: var(--ok);"></span>
|
||||
Label
|
||||
</span>
|
||||
```
|
||||
|
||||
**Chip containers:**
|
||||
```html
|
||||
<!-- Flexbox inline chips (existing) -->
|
||||
<div class="chips-inline">
|
||||
<span class="chip">Tag 1</span>
|
||||
<span class="chip">Tag 2</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid auto-fit chips (new - responsive) -->
|
||||
<div class="chips-grid">
|
||||
<span class="chip">Item 1</span>
|
||||
<span class="chip">Item 2</span>
|
||||
<span class="chip">Item 3</span>
|
||||
</div>
|
||||
|
||||
<!-- Small grid (90px min) -->
|
||||
<div class="chips-grid chips-grid-sm">...</div>
|
||||
|
||||
<!-- Large grid (160px min) -->
|
||||
<div class="chips-grid chips-grid-lg">...</div>
|
||||
```
|
||||
|
||||
### Summary Panels
|
||||
|
||||
Responsive grid panels for dashboard-style layouts:
|
||||
|
||||
```html
|
||||
<div class="summary-panels">
|
||||
<div class="summary-panel">
|
||||
<div class="summary-panel-header">Panel Title</div>
|
||||
<div class="summary-panel-content">
|
||||
Panel content here
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-panel">
|
||||
<div class="summary-panel-header">Another Panel</div>
|
||||
<div class="summary-panel-content">
|
||||
More content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Panels automatically flow into columns based on available width (240px min per column).
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
The UI uses CSS Grid `auto-fit` patterns that adapt naturally to viewport width:
|
||||
|
||||
- **Mobile** (< 640px): Single column layouts
|
||||
- **Tablet** (640px - 900px): 2-column where space allows
|
||||
- **Desktop** (> 900px): Multi-column with `auto-fit`
|
||||
|
||||
Grid patterns automatically adjust without media queries:
|
||||
```css
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Focus Indicators
|
||||
All interactive elements receive a visible focus ring:
|
||||
```css
|
||||
.focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Color Contrast
|
||||
- Text on backgrounds: Minimum 4.5:1 ratio (WCAG AA)
|
||||
- Large text/headings: Minimum 3:1 ratio
|
||||
- Interactive elements: Sufficient contrast for all states
|
||||
|
||||
### Keyboard Navigation
|
||||
- Tab order follows visual flow
|
||||
- Skip links available for main content areas
|
||||
- All controls accessible via keyboard
|
||||
|
||||
## Theme Support
|
||||
|
||||
The app supports multiple themes via `data-theme` attribute:
|
||||
|
||||
- `dark` (default): Dark mode optimized
|
||||
- `light-blend`: Light mode with warm tones
|
||||
- `high-contrast`: Maximum contrast for visibility
|
||||
- `cb-friendly`: Color-blind friendly palette
|
||||
|
||||
Themes automatically adjust all token values.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use tokens over hardcoded values**
|
||||
- ✅ `padding: var(--space-md)`
|
||||
- ❌ `padding: 12px`
|
||||
|
||||
2. **Leverage auto-fit grids for responsive layouts**
|
||||
- ✅ `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr))`
|
||||
- ❌ Multiple media queries with fixed columns
|
||||
|
||||
3. **Maintain semantic color usage**
|
||||
- Use `--ok`, `--warn`, `--err` for states
|
||||
- Use MTG colors for identity-specific UI
|
||||
- Use `--text`, `--muted` for typography hierarchy
|
||||
|
||||
4. **Keep components DRY**
|
||||
- Reuse `.chip`, `.summary-panel`, `.chips-grid` patterns
|
||||
- Extend with modifiers, not duplicates
|
||||
|
||||
5. **Test across viewports**
|
||||
- Verify auto-fit breakpoints work smoothly
|
||||
- Check mobile (375px), tablet (768px), desktop (1440px)
|
||||
Loading…
Add table
Add a link
Reference in a new issue