mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat: add collapsible analytics, click-to-pin chart tooltips, and extended virtualization
This commit is contained in:
parent
3877890889
commit
20b9e8037c
10 changed files with 1036 additions and 202 deletions
26
CHANGELOG.md
26
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
|
||||
|
|
@ -18,19 +13,26 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- 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
|
||||
- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel.
|
||||
- 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
|
||||
- Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request.
|
||||
- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks.
|
||||
- Build wizard Step 5 now emits optimistic include/exclude updates using cached HTMX fragments, prefetch metadata, and persistent summary containers for pending must-have selections.
|
||||
- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts.
|
||||
- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset.
|
||||
- Must-have include/exclude buttons are hidden by default behind a new `SHOW_MUST_HAVE_BUTTONS` env toggle and now ship with tooltips explaining how they differ from locks.
|
||||
- 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_
|
||||
|
|
|
|||
|
|
@ -140,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",
|
||||
|
|
@ -772,19 +856,7 @@ async def toggle_must_haves(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
must_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)),
|
||||
}
|
||||
|
||||
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
|
||||
ctx["must_have_state"] = must_state
|
||||
ctx["summary"] = None
|
||||
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
response = _render_include_exclude_summary(request, sess, sid)
|
||||
|
||||
try:
|
||||
log_include_exclude_toggle(
|
||||
|
|
@ -806,7 +878,7 @@ async def toggle_must_haves(
|
|||
"exclude_count": len(excludes),
|
||||
}
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
|
||||
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
|
@ -2377,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
|
||||
|
||||
|
||||
|
|
@ -3016,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)
|
||||
|
|
@ -3086,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
|
||||
|
|
@ -3100,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)
|
||||
|
|
@ -3138,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
|
||||
|
|
@ -3164,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
|
||||
|
||||
|
||||
|
|
@ -3205,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
|
||||
|
|
@ -3218,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)
|
||||
|
|
@ -3283,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")
|
||||
|
|
@ -4376,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
|
||||
|
|
@ -4443,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
|
||||
|
|
@ -4490,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
|
||||
|
||||
|
||||
|
|
@ -4519,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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,19 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
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,
|
||||
|
|
@ -428,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
|
||||
|
||||
|
||||
|
|
@ -463,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
|
||||
|
|
@ -494,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
|
||||
|
|
|
|||
|
|
@ -800,7 +800,6 @@
|
|||
try{
|
||||
var body = document.body || document.documentElement;
|
||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||
// Global diagnostics aggregator
|
||||
var GLOBAL = (function(){
|
||||
if (!DIAG) return null;
|
||||
if (window.__virtGlobal) return window.__virtGlobal;
|
||||
|
|
@ -821,7 +820,6 @@
|
|||
el.style.zIndex = '50';
|
||||
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
||||
el.style.cursor = 'default';
|
||||
// Hidden by default; toggle with 'v'
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
store.summaryEl = el;
|
||||
|
|
@ -837,7 +835,7 @@
|
|||
visible += (g[i].end||0) - (g[i].start||0);
|
||||
lastMs = Math.max(lastMs, g[i].lastMs||0);
|
||||
}
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+(lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs))+'ms';
|
||||
}
|
||||
function register(gridId, ref){
|
||||
store.grids.push({ id: gridId, ref: ref });
|
||||
|
|
@ -852,48 +850,66 @@
|
|||
}
|
||||
update();
|
||||
},
|
||||
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
|
||||
toggle: function(){
|
||||
var el = ensure();
|
||||
el.style.display = (el.style.display === 'none' ? '' : 'none');
|
||||
}
|
||||
};
|
||||
}
|
||||
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
|
||||
window.__virtGlobal = {
|
||||
register: register,
|
||||
toggle: function(){
|
||||
var el = ensure();
|
||||
el.style.display = (el.style.display === 'none' ? '' : 'none');
|
||||
}
|
||||
};
|
||||
return window.__virtGlobal;
|
||||
})();
|
||||
// Support card grids and other scroll containers (e.g., #owned-box)
|
||||
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
|
||||
|
||||
var scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
var grids = scope.querySelectorAll('[data-virtualize]');
|
||||
if (!grids.length) return;
|
||||
|
||||
grids.forEach(function(grid){
|
||||
if (grid.__virtBound) return;
|
||||
grid.__virtBound = true;
|
||||
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
|
||||
if (!grid || grid.__virtBound) return;
|
||||
var attrVal = (grid.getAttribute('data-virtualize') || '').trim();
|
||||
if (!attrVal || /^0|false$/i.test(attrVal)) return;
|
||||
|
||||
var container = grid;
|
||||
container.style.position = container.style.position || 'relative';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
// Ensure wrapper itself is a grid to preserve multi-column layout inside
|
||||
// when the container (e.g., .card-grid) is virtualized.
|
||||
wrapper.style.display = 'grid';
|
||||
// Move children into a fragment store (for owned, children live under UL)
|
||||
|
||||
var mode = attrVal.toLowerCase();
|
||||
var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10);
|
||||
var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10);
|
||||
var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10);
|
||||
var maxHeightAttr = grid.getAttribute('data-virtualize-max-height') || (grid.dataset ? grid.dataset.virtualizeMaxHeight : '');
|
||||
var overflowAttr = grid.getAttribute('data-virtualize-overflow') || (grid.dataset ? grid.dataset.virtualizeOverflow : '');
|
||||
|
||||
var source = container;
|
||||
// If this is the owned box, use the UL inside as the source list
|
||||
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
|
||||
if (ownedGrid) { source = ownedGrid; }
|
||||
if (!source || !source.children || !source.children.length) return;
|
||||
|
||||
var all = Array.prototype.slice.call(source.children);
|
||||
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
|
||||
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
|
||||
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
|
||||
// is cheaper than the complexity for small sets.
|
||||
var MIN_VIRT_ITEMS = 80;
|
||||
if (all.length < MIN_VIRT_ITEMS){
|
||||
// Mark as processed so we don't attempt again on HTMX swaps.
|
||||
return; // children remain in place; no virtualization applied.
|
||||
}
|
||||
all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } });
|
||||
var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80;
|
||||
if (all.length < minItems) return;
|
||||
|
||||
grid.__virtBound = true;
|
||||
|
||||
var store = document.createElement('div');
|
||||
store.style.display = 'none';
|
||||
all.forEach(function(n){ store.appendChild(n); });
|
||||
all.forEach(function(node){ store.appendChild(node); });
|
||||
|
||||
var padTop = document.createElement('div');
|
||||
var padBottom = document.createElement('div');
|
||||
padTop.style.height = '0px'; padBottom.style.height = '0px';
|
||||
// For owned, keep the UL but render into it; otherwise append wrapper to container
|
||||
padTop.style.height = '0px';
|
||||
padBottom.style.height = '0px';
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
|
||||
if (ownedGrid){
|
||||
ownedGrid.innerHTML = '';
|
||||
ownedGrid.appendChild(padTop);
|
||||
|
|
@ -901,17 +917,34 @@
|
|||
ownedGrid.appendChild(padBottom);
|
||||
ownedGrid.appendChild(store);
|
||||
} else {
|
||||
container.appendChild(padTop);
|
||||
container.appendChild(wrapper);
|
||||
container.appendChild(padBottom);
|
||||
container.appendChild(store);
|
||||
}
|
||||
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
|
||||
var perRow = 1;
|
||||
// Optional diagnostics overlay
|
||||
|
||||
if (maxHeightAttr){
|
||||
container.style.maxHeight = maxHeightAttr;
|
||||
} else if (!container.style.maxHeight){
|
||||
container.style.maxHeight = '70vh';
|
||||
}
|
||||
if (overflowAttr){
|
||||
container.style.overflow = overflowAttr;
|
||||
} else if (!container.style.overflow){
|
||||
container.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
var baseRow = container.id === 'owned-box' ? 160 : (mode.indexOf('list') > -1 ? 110 : 240);
|
||||
var minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow;
|
||||
var rowH = minRowH;
|
||||
var explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null;
|
||||
var perRow = explicitCols || 1;
|
||||
|
||||
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
|
||||
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
||||
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
||||
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
|
||||
|
||||
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
|
||||
function ensureDiag(){
|
||||
if (!DIAG) return null;
|
||||
|
|
@ -928,8 +961,7 @@
|
|||
diagBox.style.fontSize = '12px';
|
||||
diagBox.style.margin = '0 0 .35rem 0';
|
||||
diagBox.style.color = '#cbd5e1';
|
||||
diagBox.style.display = 'none'; // hidden until toggled
|
||||
// Controls
|
||||
diagBox.style.display = 'none';
|
||||
var controls = document.createElement('div');
|
||||
controls.style.display = 'flex';
|
||||
controls.style.gap = '.35rem';
|
||||
|
|
@ -937,74 +969,116 @@
|
|||
controls.style.marginBottom = '.25rem';
|
||||
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
|
||||
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
|
||||
btnCopy.addEventListener('click', function(){ try{ var payload = {
|
||||
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
|
||||
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
|
||||
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
|
||||
btnCopy.addEventListener('click', function(){
|
||||
try{
|
||||
var payload = {
|
||||
id: gridId,
|
||||
rowH: rowH,
|
||||
perRow: perRow,
|
||||
start: start,
|
||||
end: end,
|
||||
total: total,
|
||||
renderCount: renderCount,
|
||||
measureCount: measureCount,
|
||||
swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs,
|
||||
lastRenderAt: lastRenderAt,
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
btnCopy.textContent = 'Copied';
|
||||
setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200);
|
||||
}catch(_){ }
|
||||
});
|
||||
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
|
||||
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
|
||||
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
|
||||
controls.appendChild(title);
|
||||
controls.appendChild(btnCopy);
|
||||
controls.appendChild(btnHide);
|
||||
diagBox.appendChild(controls);
|
||||
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
||||
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
||||
host.insertBefore(diagBox, host.firstChild);
|
||||
return diagBox;
|
||||
}
|
||||
|
||||
function measure(){
|
||||
try {
|
||||
measureCount++;
|
||||
// create a temp tile to measure if none
|
||||
var probe = store.firstElementChild || all[0];
|
||||
if (probe){
|
||||
var fake = probe.cloneNode(true);
|
||||
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
|
||||
fake.style.position = 'absolute';
|
||||
fake.style.visibility = 'hidden';
|
||||
fake.style.pointerEvents = 'none';
|
||||
(ownedGrid || container).appendChild(fake);
|
||||
var rect = fake.getBoundingClientRect();
|
||||
rowH = Math.max(120, Math.ceil(rect.height) + 16);
|
||||
rowH = Math.max(minRowH, Math.ceil(rect.height) + 16);
|
||||
(ownedGrid || container).removeChild(fake);
|
||||
}
|
||||
// Estimate perRow via computed styles of grid
|
||||
var style = window.getComputedStyle(ownedGrid || container);
|
||||
var cols = style.getPropertyValue('grid-template-columns');
|
||||
// Mirror grid settings onto the wrapper so its children still flow in columns
|
||||
try {
|
||||
var displayMode = style.getPropertyValue('display');
|
||||
if (displayMode && displayMode.trim()){
|
||||
wrapper.style.display = displayMode;
|
||||
} else if (!wrapper.style.display){
|
||||
wrapper.style.display = 'grid';
|
||||
}
|
||||
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
|
||||
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
||||
if (gap && gap.trim()) wrapper.style.gap = gap;
|
||||
// Inherit justify/align if present
|
||||
var ji = style.getPropertyValue('justify-items');
|
||||
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
||||
var ai = style.getPropertyValue('align-items');
|
||||
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
||||
} catch(_){ }
|
||||
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
|
||||
var derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){
|
||||
return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);
|
||||
}).length : 0);
|
||||
if (explicitCols){
|
||||
perRow = explicitCols;
|
||||
} else if (derivedCols){
|
||||
perRow = Math.max(1, derivedCols);
|
||||
} else {
|
||||
perRow = Math.max(1, perRow);
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
measure();
|
||||
var total = all.length;
|
||||
var start = 0, end = 0;
|
||||
|
||||
function render(){
|
||||
var t0 = DIAG ? performance.now() : 0;
|
||||
var scroller = container;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var scrollTop = scroller.scrollTop;
|
||||
// If container isn’t scrollable, use window scroll offset
|
||||
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
|
||||
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
|
||||
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
|
||||
var newStart = rowStart * perRow;
|
||||
var newEnd = Math.min(total, rowEnd * perRow);
|
||||
if (newStart === start && newEnd === end) return; // no change
|
||||
start = newStart; end = newEnd;
|
||||
// Padding
|
||||
var beforeRows = Math.floor(start / perRow);
|
||||
var afterRows = Math.ceil((total - end) / perRow);
|
||||
var vh, scrollTop, top;
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Window-scroll mode: measure relative to viewport
|
||||
vh = window.innerHeight;
|
||||
var rect = container.getBoundingClientRect();
|
||||
top = Math.max(0, -rect.top);
|
||||
scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
} else {
|
||||
// Container-scroll mode: measure relative to container
|
||||
vh = scroller.clientHeight || window.innerHeight;
|
||||
scrollTop = scroller.scrollTop;
|
||||
top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
}
|
||||
|
||||
var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2;
|
||||
var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1);
|
||||
var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow)));
|
||||
var newStart = rowStart * Math.max(1, perRow);
|
||||
var newEnd = Math.min(total, rowEnd * Math.max(1, perRow));
|
||||
if (newStart === start && newEnd === end) return;
|
||||
start = newStart;
|
||||
end = newEnd;
|
||||
var beforeRows = Math.floor(start / Math.max(1, perRow));
|
||||
var afterRows = Math.ceil((total - end) / Math.max(1, perRow));
|
||||
padTop.style.height = (beforeRows * rowH) + 'px';
|
||||
padBottom.style.height = (afterRows * rowH) + 'px';
|
||||
// Render visible children
|
||||
wrapper.innerHTML = '';
|
||||
for (var i = start; i < end; i++){
|
||||
var node = all[i];
|
||||
|
|
@ -1013,31 +1087,86 @@
|
|||
if (DIAG){
|
||||
var box = ensureDiag();
|
||||
if (box){
|
||||
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
|
||||
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
|
||||
var dt = performance.now() - t0;
|
||||
lastRenderMs = dt;
|
||||
renderCount++;
|
||||
lastRenderAt = Date.now();
|
||||
var vis = end - start;
|
||||
var rowsTotal = Math.ceil(total / Math.max(1, perRow));
|
||||
var textEl = box.querySelector('.virt-diag-text');
|
||||
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
|
||||
textEl.textContent = msg;
|
||||
// Health hint
|
||||
var bad = (dt > 33) || (vis > 300);
|
||||
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
||||
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
|
||||
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
|
||||
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
|
||||
if (globalReg && globalReg.set){
|
||||
globalReg.set({ total: total, start: start, end: end, lastMs: dt });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(){ render(); }
|
||||
function onResize(){ measure(); render(); }
|
||||
container.addEventListener('scroll', onScroll);
|
||||
|
||||
// Support both container-scroll (default) and window-scroll modes
|
||||
var scrollMode = overflowAttr || container.style.overflow || 'auto';
|
||||
var useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window');
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Window-scroll mode: listen to window scroll events
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
} else {
|
||||
// Container-scroll mode: listen to container scroll events
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
// Initial size; ensure container is scrollable for our logic
|
||||
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
|
||||
container.style.overflow = container.style.overflow || 'auto';
|
||||
|
||||
render();
|
||||
// Re-render after filters resort or HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
|
||||
// Keyboard toggle for overlays: 'v'
|
||||
|
||||
// Track cleanup for disconnected containers
|
||||
grid.__virtCleanup = function(){
|
||||
try {
|
||||
if (useWindowScroll) {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
} else {
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
window.removeEventListener('resize', onResize);
|
||||
} catch(_){}
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
if (!container.isConnected) return;
|
||||
if (!container.contains(ev.target)) return;
|
||||
swapCount++;
|
||||
var merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children));
|
||||
var known = new Map();
|
||||
all.forEach(function(node, idx){
|
||||
var index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx;
|
||||
known.set(node, index);
|
||||
});
|
||||
var nextIndex = known.size;
|
||||
merged.forEach(function(node){
|
||||
if (!known.has(node)){
|
||||
node.__virtIndex = nextIndex;
|
||||
known.set(node, nextIndex);
|
||||
nextIndex++;
|
||||
}
|
||||
});
|
||||
merged.sort(function(a, b){
|
||||
var ia = known.get(a);
|
||||
var ib = known.get(b);
|
||||
return (ia - ib);
|
||||
});
|
||||
merged.forEach(function(node, idx){ node.__virtIndex = idx; });
|
||||
all = merged;
|
||||
total = all.length;
|
||||
measure();
|
||||
render();
|
||||
});
|
||||
|
||||
if (DIAG && !window.__virtHotkeyBound){
|
||||
window.__virtHotkeyBound = true;
|
||||
document.addEventListener('keydown', function(e){
|
||||
|
|
@ -1045,9 +1174,11 @@
|
|||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key && e.key.toLowerCase() === 'v'){
|
||||
e.preventDefault();
|
||||
// Toggle all virt-diag boxes and the global summary
|
||||
var shown = null;
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){
|
||||
if (shown === null) shown = (b.style.display === 'none');
|
||||
b.style.display = shown ? '' : 'none';
|
||||
});
|
||||
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
|
||||
}
|
||||
}catch(_){ }
|
||||
|
|
@ -1198,4 +1329,61 @@
|
|||
});
|
||||
}catch(_){ }
|
||||
});
|
||||
|
||||
// --- Lazy-loading analytics accordions ---
|
||||
function initLazyAccordions(root){
|
||||
try {
|
||||
var scope = root || document;
|
||||
if (!scope || !scope.querySelectorAll) return;
|
||||
|
||||
scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){
|
||||
if (!details || details.__lazyBound) return;
|
||||
details.__lazyBound = true;
|
||||
|
||||
var loaded = false;
|
||||
|
||||
details.addEventListener('toggle', function(){
|
||||
if (!details.open || loaded) return;
|
||||
loaded = true;
|
||||
|
||||
// Mark as loaded to prevent re-initialization
|
||||
var content = details.querySelector('.analytics-content');
|
||||
if (!content) return;
|
||||
|
||||
// Remove placeholder if present
|
||||
var placeholder = content.querySelector('.analytics-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
|
||||
// Content is already rendered in the template, just need to initialize any scripts
|
||||
// Re-run virtualization if needed
|
||||
try {
|
||||
initVirtualization(content);
|
||||
} catch(_){}
|
||||
|
||||
// Re-attach chart interactivity if this is mana overview
|
||||
var type = details.getAttribute('data-analytics-type');
|
||||
if (type === 'mana') {
|
||||
try {
|
||||
// Tooltip and highlight logic is already in the template scripts
|
||||
// Just trigger a synthetic event to re-attach if needed
|
||||
var event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } });
|
||||
details.dispatchEvent(event);
|
||||
} catch(_){}
|
||||
}
|
||||
|
||||
// Send telemetry
|
||||
telemetry.send('analytics.accordion_expand', {
|
||||
type: type || 'unknown',
|
||||
accordion: details.id || 'unnamed',
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(_){}
|
||||
}
|
||||
|
||||
// Initialize on load and after HTMX swaps
|
||||
document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(); });
|
||||
document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -627,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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %}>
|
||||
|
|
|
|||
|
|
@ -462,11 +462,16 @@
|
|||
<!-- controls now above -->
|
||||
|
||||
{% if allow_must_haves %}
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
{% endif %}
|
||||
{% if status and status.startswith('Build complete') and summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -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,8 +210,7 @@
|
|||
{% 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 }}%">
|
||||
<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 []) %}
|
||||
|
|
@ -218,13 +224,12 @@
|
|||
{% 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>
|
||||
<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,8 +265,6 @@
|
|||
{% 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 []) %}
|
||||
|
|
@ -270,12 +273,12 @@
|
|||
{% 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>
|
||||
<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,8 +301,6 @@
|
|||
{% 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 %}
|
||||
|
|
@ -307,12 +308,12 @@
|
|||
{% 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>
|
||||
<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');
|
||||
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;
|
||||
|
|
@ -696,3 +827,4 @@
|
|||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<div id="include-exclude-summary" data-summary>
|
||||
{% 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 [] %}
|
||||
|
|
|
|||
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