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
|
# 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.
|
This format follows Keep a Changelog principles and aims for Semantic Versioning.
|
||||||
|
|
||||||
## How we version
|
## 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.
|
- 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 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.
|
- 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
|
### 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.
|
- 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`.
|
- 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
|
### 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.
|
- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type.
|
||||||
- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks.
|
- Card grids use modern browser containment rules to minimize layout recalculations 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.
|
- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees.
|
||||||
- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts.
|
- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms.
|
||||||
- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset.
|
- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively.
|
||||||
- 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.
|
- Chart hover zones expanded to full column width for easier interaction.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- _None_
|
- _None_
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,90 @@ def warm_validation_name_cache() -> None:
|
||||||
pass
|
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 = {
|
_COLOR_NAME_MAP = {
|
||||||
"W": "White",
|
"W": "White",
|
||||||
"U": "Blue",
|
"U": "Blue",
|
||||||
|
|
@ -772,19 +856,7 @@ async def toggle_must_haves(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
must_state = {
|
response = _render_include_exclude_summary(request, sess, sid)
|
||||||
"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")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_include_exclude_toggle(
|
log_include_exclude_toggle(
|
||||||
|
|
@ -806,7 +878,7 @@ async def toggle_must_haves(
|
||||||
"exclude_count": len(excludes),
|
"exclude_count": len(excludes),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
|
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return response
|
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}")
|
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
|
||||||
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
|
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3016,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
||||||
base = step5_empty_ctx(request, sess)
|
base = step5_empty_ctx(request, sess)
|
||||||
resp = templates.TemplateResponse("build/_step5.html", base)
|
resp = templates.TemplateResponse("build/_step5.html", base)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@router.post("/step5/continue", response_class=HTMLResponse)
|
@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}")
|
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
stage_label = res.get("label")
|
stage_label = res.get("label")
|
||||||
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
|
# 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)
|
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||||
resp = templates.TemplateResponse("build/_step5.html", ctx2)
|
resp = templates.TemplateResponse("build/_step5.html", ctx2)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@router.post("/step5/rerun", response_class=HTMLResponse)
|
@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}")
|
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
sess["last_step"] = 5
|
sess["last_step"] = 5
|
||||||
# Build locked cards list with ownership and in-deck presence
|
# 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
|
ctx3["locked_cards"] = locked_cards
|
||||||
resp = templates.TemplateResponse("build/_step5.html", ctx3)
|
resp = templates.TemplateResponse("build/_step5.html", ctx3)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
|
||||||
return resp
|
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)
|
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
|
||||||
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
resp = templates.TemplateResponse("build/_step5.html", ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Surface a friendly error on the step 5 screen with normalized context
|
# 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
|
err_ctx["commander"] = commander
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@router.get("/step5/start", response_class=HTMLResponse)
|
@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 = templates.TemplateResponse("build/_step5.html", base)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
|
||||||
return resp
|
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 ---
|
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
||||||
|
|
||||||
@router.post("/lock")
|
@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.")
|
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
# Ensure we have a CSV base stem for consistent re-exports
|
# Ensure we have a CSV base stem for consistent re-exports
|
||||||
base_stem = None
|
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}")
|
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
|
||||||
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
return resp
|
||||||
# Reload compliance JSON and summary
|
# Reload compliance JSON and summary
|
||||||
compliance = None
|
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)
|
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 = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
|
||||||
return resp
|
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]
|
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
|
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
|
||||||
return resp
|
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")
|
ctx["name"] = sess.get("custom_export_base")
|
||||||
if include_locks:
|
if include_locks:
|
||||||
ctx["locks"] = list(sess.get("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"] = {
|
ctx["must_have_state"] = {
|
||||||
"includes": include_cards,
|
"includes": include_cards,
|
||||||
"excludes": exclude_cards,
|
"excludes": exclude_cards,
|
||||||
|
|
@ -406,7 +419,7 @@ def step5_ctx_from_result(
|
||||||
"csv_path": res.get("csv_path") if done else None,
|
"csv_path": res.get("csv_path") if done else None,
|
||||||
"txt_path": res.get("txt_path") if done else None,
|
"txt_path": res.get("txt_path") if done else None,
|
||||||
"summary": res.get("summary") 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),
|
"show_skipped": bool(show_skipped),
|
||||||
"total_cards": res.get("total_cards"),
|
"total_cards": res.get("total_cards"),
|
||||||
"added_total": res.get("added_total"),
|
"added_total": res.get("added_total"),
|
||||||
|
|
@ -414,7 +427,7 @@ def step5_ctx_from_result(
|
||||||
"clamped_overflow": res.get("clamped_overflow"),
|
"clamped_overflow": res.get("clamped_overflow"),
|
||||||
"mc_summary": res.get("mc_summary"),
|
"mc_summary": res.get("mc_summary"),
|
||||||
"skipped": bool(res.get("skipped")),
|
"skipped": bool(res.get("skipped")),
|
||||||
"gated": bool(res.get("gated")),
|
"gated": bool(res.get("gated")),
|
||||||
}
|
}
|
||||||
if extras:
|
if extras:
|
||||||
ctx.update(extras)
|
ctx.update(extras)
|
||||||
|
|
@ -428,6 +441,57 @@ def step5_ctx_from_result(
|
||||||
ctx.update(hover_meta)
|
ctx.update(hover_meta)
|
||||||
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
|
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
|
||||||
ctx["commander_display_name"] = ctx.get("commander")
|
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
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -463,6 +527,25 @@ def step5_error_ctx(
|
||||||
"added_total": 0,
|
"added_total": 0,
|
||||||
"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:
|
if extras:
|
||||||
ctx.update(extras)
|
ctx.update(extras)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
@ -494,6 +577,25 @@ def step5_empty_ctx(
|
||||||
"show_skipped": False,
|
"show_skipped": False,
|
||||||
"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:
|
if extras:
|
||||||
ctx.update(extras)
|
ctx.update(extras)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
|
||||||
|
|
@ -798,9 +798,8 @@
|
||||||
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
|
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
|
||||||
function initVirtualization(root){
|
function initVirtualization(root){
|
||||||
try{
|
try{
|
||||||
var body = document.body || document.documentElement;
|
var body = document.body || document.documentElement;
|
||||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||||
// Global diagnostics aggregator
|
|
||||||
var GLOBAL = (function(){
|
var GLOBAL = (function(){
|
||||||
if (!DIAG) return null;
|
if (!DIAG) return null;
|
||||||
if (window.__virtGlobal) return window.__virtGlobal;
|
if (window.__virtGlobal) return window.__virtGlobal;
|
||||||
|
|
@ -821,7 +820,6 @@
|
||||||
el.style.zIndex = '50';
|
el.style.zIndex = '50';
|
||||||
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
||||||
el.style.cursor = 'default';
|
el.style.cursor = 'default';
|
||||||
// Hidden by default; toggle with 'v'
|
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
store.summaryEl = el;
|
store.summaryEl = el;
|
||||||
|
|
@ -837,7 +835,7 @@
|
||||||
visible += (g[i].end||0) - (g[i].start||0);
|
visible += (g[i].end||0) - (g[i].start||0);
|
||||||
lastMs = Math.max(lastMs, g[i].lastMs||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){
|
function register(gridId, ref){
|
||||||
store.grids.push({ id: gridId, ref: ref });
|
store.grids.push({ id: gridId, ref: ref });
|
||||||
|
|
@ -852,48 +850,66 @@
|
||||||
}
|
}
|
||||||
update();
|
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;
|
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;
|
if (!grids.length) return;
|
||||||
|
|
||||||
grids.forEach(function(grid){
|
grids.forEach(function(grid){
|
||||||
if (grid.__virtBound) return;
|
if (!grid || grid.__virtBound) return;
|
||||||
grid.__virtBound = true;
|
var attrVal = (grid.getAttribute('data-virtualize') || '').trim();
|
||||||
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
|
if (!attrVal || /^0|false$/i.test(attrVal)) return;
|
||||||
|
|
||||||
var container = grid;
|
var container = grid;
|
||||||
container.style.position = container.style.position || 'relative';
|
container.style.position = container.style.position || 'relative';
|
||||||
var wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'virt-wrapper';
|
var mode = attrVal.toLowerCase();
|
||||||
// Ensure wrapper itself is a grid to preserve multi-column layout inside
|
var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10);
|
||||||
// when the container (e.g., .card-grid) is virtualized.
|
var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10);
|
||||||
wrapper.style.display = 'grid';
|
var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10);
|
||||||
// Move children into a fragment store (for owned, children live under UL)
|
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;
|
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;
|
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
|
||||||
if (ownedGrid) { source = ownedGrid; }
|
if (ownedGrid) { source = ownedGrid; }
|
||||||
|
if (!source || !source.children || !source.children.length) return;
|
||||||
|
|
||||||
var all = Array.prototype.slice.call(source.children);
|
var all = Array.prototype.slice.call(source.children);
|
||||||
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
|
all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } });
|
||||||
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
|
var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80;
|
||||||
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
|
if (all.length < minItems) return;
|
||||||
// is cheaper than the complexity for small sets.
|
|
||||||
var MIN_VIRT_ITEMS = 80;
|
grid.__virtBound = true;
|
||||||
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.
|
|
||||||
}
|
|
||||||
var store = document.createElement('div');
|
var store = document.createElement('div');
|
||||||
store.style.display = 'none';
|
store.style.display = 'none';
|
||||||
all.forEach(function(n){ store.appendChild(n); });
|
all.forEach(function(node){ store.appendChild(node); });
|
||||||
|
|
||||||
var padTop = document.createElement('div');
|
var padTop = document.createElement('div');
|
||||||
var padBottom = document.createElement('div');
|
var padBottom = document.createElement('div');
|
||||||
padTop.style.height = '0px'; padBottom.style.height = '0px';
|
padTop.style.height = '0px';
|
||||||
// For owned, keep the UL but render into it; otherwise append wrapper to container
|
padBottom.style.height = '0px';
|
||||||
|
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'virt-wrapper';
|
||||||
|
|
||||||
if (ownedGrid){
|
if (ownedGrid){
|
||||||
ownedGrid.innerHTML = '';
|
ownedGrid.innerHTML = '';
|
||||||
ownedGrid.appendChild(padTop);
|
ownedGrid.appendChild(padTop);
|
||||||
|
|
@ -901,17 +917,34 @@
|
||||||
ownedGrid.appendChild(padBottom);
|
ownedGrid.appendChild(padBottom);
|
||||||
ownedGrid.appendChild(store);
|
ownedGrid.appendChild(store);
|
||||||
} else {
|
} else {
|
||||||
|
container.appendChild(padTop);
|
||||||
container.appendChild(wrapper);
|
container.appendChild(wrapper);
|
||||||
container.appendChild(padBottom);
|
container.appendChild(padBottom);
|
||||||
container.appendChild(store);
|
container.appendChild(store);
|
||||||
}
|
}
|
||||||
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
|
|
||||||
var perRow = 1;
|
if (maxHeightAttr){
|
||||||
// Optional diagnostics overlay
|
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 diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
|
||||||
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
||||||
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
||||||
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
|
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 fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
|
||||||
function ensureDiag(){
|
function ensureDiag(){
|
||||||
if (!DIAG) return null;
|
if (!DIAG) return null;
|
||||||
|
|
@ -928,8 +961,7 @@
|
||||||
diagBox.style.fontSize = '12px';
|
diagBox.style.fontSize = '12px';
|
||||||
diagBox.style.margin = '0 0 .35rem 0';
|
diagBox.style.margin = '0 0 .35rem 0';
|
||||||
diagBox.style.color = '#cbd5e1';
|
diagBox.style.color = '#cbd5e1';
|
||||||
diagBox.style.display = 'none'; // hidden until toggled
|
diagBox.style.display = 'none';
|
||||||
// Controls
|
|
||||||
var controls = document.createElement('div');
|
var controls = document.createElement('div');
|
||||||
controls.style.display = 'flex';
|
controls.style.display = 'flex';
|
||||||
controls.style.gap = '.35rem';
|
controls.style.gap = '.35rem';
|
||||||
|
|
@ -937,107 +969,204 @@
|
||||||
controls.style.marginBottom = '.25rem';
|
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 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';
|
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
|
||||||
btnCopy.addEventListener('click', function(){ try{ var payload = {
|
btnCopy.addEventListener('click', function(){
|
||||||
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
|
try{
|
||||||
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
|
var payload = {
|
||||||
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
|
id: gridId,
|
||||||
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
|
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';
|
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
|
||||||
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
|
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);
|
diagBox.appendChild(controls);
|
||||||
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
||||||
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
||||||
host.insertBefore(diagBox, host.firstChild);
|
host.insertBefore(diagBox, host.firstChild);
|
||||||
return diagBox;
|
return diagBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
function measure(){
|
function measure(){
|
||||||
try {
|
try {
|
||||||
measureCount++;
|
measureCount++;
|
||||||
// create a temp tile to measure if none
|
|
||||||
var probe = store.firstElementChild || all[0];
|
var probe = store.firstElementChild || all[0];
|
||||||
if (probe){
|
if (probe){
|
||||||
var fake = probe.cloneNode(true);
|
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);
|
(ownedGrid || container).appendChild(fake);
|
||||||
var rect = fake.getBoundingClientRect();
|
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);
|
(ownedGrid || container).removeChild(fake);
|
||||||
}
|
}
|
||||||
// Estimate perRow via computed styles of grid
|
|
||||||
var style = window.getComputedStyle(ownedGrid || container);
|
var style = window.getComputedStyle(ownedGrid || container);
|
||||||
var cols = style.getPropertyValue('grid-template-columns');
|
var cols = style.getPropertyValue('grid-template-columns');
|
||||||
// Mirror grid settings onto the wrapper so its children still flow in columns
|
|
||||||
try {
|
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;
|
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
|
||||||
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
||||||
if (gap && gap.trim()) wrapper.style.gap = gap;
|
if (gap && gap.trim()) wrapper.style.gap = gap;
|
||||||
// Inherit justify/align if present
|
|
||||||
var ji = style.getPropertyValue('justify-items');
|
var ji = style.getPropertyValue('justify-items');
|
||||||
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
||||||
var ai = style.getPropertyValue('align-items');
|
var ai = style.getPropertyValue('align-items');
|
||||||
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
||||||
} catch(_) {}
|
} 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){
|
||||||
} catch(_){}
|
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();
|
measure();
|
||||||
var total = all.length;
|
var total = all.length;
|
||||||
var start = 0, end = 0;
|
var start = 0, end = 0;
|
||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
var t0 = DIAG ? performance.now() : 0;
|
var t0 = DIAG ? performance.now() : 0;
|
||||||
var scroller = container;
|
var scroller = container;
|
||||||
var vh = scroller.clientHeight || window.innerHeight;
|
var vh, scrollTop, top;
|
||||||
var scrollTop = scroller.scrollTop;
|
|
||||||
// If container isn’t scrollable, use window scroll offset
|
if (useWindowScroll) {
|
||||||
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
// Window-scroll mode: measure relative to viewport
|
||||||
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
|
vh = window.innerHeight;
|
||||||
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
|
var rect = container.getBoundingClientRect();
|
||||||
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
|
top = Math.max(0, -rect.top);
|
||||||
var newStart = rowStart * perRow;
|
scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||||
var newEnd = Math.min(total, rowEnd * perRow);
|
} else {
|
||||||
if (newStart === start && newEnd === end) return; // no change
|
// Container-scroll mode: measure relative to container
|
||||||
start = newStart; end = newEnd;
|
vh = scroller.clientHeight || window.innerHeight;
|
||||||
// Padding
|
scrollTop = scroller.scrollTop;
|
||||||
var beforeRows = Math.floor(start / perRow);
|
top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||||
var afterRows = Math.ceil((total - end) / perRow);
|
}
|
||||||
|
|
||||||
|
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';
|
padTop.style.height = (beforeRows * rowH) + 'px';
|
||||||
padBottom.style.height = (afterRows * rowH) + 'px';
|
padBottom.style.height = (afterRows * rowH) + 'px';
|
||||||
// Render visible children
|
|
||||||
wrapper.innerHTML = '';
|
wrapper.innerHTML = '';
|
||||||
for (var i = start; i < end; i++) {
|
for (var i = start; i < end; i++){
|
||||||
var node = all[i];
|
var node = all[i];
|
||||||
if (node) wrapper.appendChild(node);
|
if (node) wrapper.appendChild(node);
|
||||||
}
|
}
|
||||||
if (DIAG){
|
if (DIAG){
|
||||||
var box = ensureDiag();
|
var box = ensureDiag();
|
||||||
if (box){
|
if (box){
|
||||||
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
|
var dt = performance.now() - t0;
|
||||||
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
|
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 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;
|
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;
|
textEl.textContent = msg;
|
||||||
// Health hint
|
|
||||||
var bad = (dt > 33) || (vis > 300);
|
var bad = (dt > 33) || (vis > 300);
|
||||||
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
||||||
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
|
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');
|
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 onScroll(){ render(); }
|
||||||
function onResize(){ measure(); 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);
|
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();
|
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(); } });
|
// Track cleanup for disconnected containers
|
||||||
// Keyboard toggle for overlays: 'v'
|
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){
|
if (DIAG && !window.__virtHotkeyBound){
|
||||||
window.__virtHotkeyBound = true;
|
window.__virtHotkeyBound = true;
|
||||||
document.addEventListener('keydown', function(e){
|
document.addEventListener('keydown', function(e){
|
||||||
|
|
@ -1045,9 +1174,11 @@
|
||||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||||
if (e.key && e.key.toLowerCase() === 'v'){
|
if (e.key && e.key.toLowerCase() === 'v'){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Toggle all virt-diag boxes and the global summary
|
|
||||||
var shown = null;
|
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();
|
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
|
||||||
}
|
}
|
||||||
}catch(_){ }
|
}catch(_){ }
|
||||||
|
|
@ -1198,4 +1329,61 @@
|
||||||
});
|
});
|
||||||
}catch(_){ }
|
}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) {
|
@media (min-width: 900px) {
|
||||||
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
|
#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 #}
|
{# Flagged tiles by category, in the same card grid style #}
|
||||||
{% if flagged_meta and flagged_meta|length > 0 %}
|
{% if flagged_meta and flagged_meta|length > 0 %}
|
||||||
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
|
<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 %}
|
{% for f in flagged_meta %}
|
||||||
{% set sev = (f.severity or 'FAIL')|upper %}
|
{% 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 %}>
|
<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 -->
|
<!-- controls now above -->
|
||||||
|
|
||||||
{% if allow_must_haves %}
|
{% if allow_must_haves %}
|
||||||
{% include "partials/include_exclude_summary.html" %}
|
{% include "partials/include_exclude_summary.html" with oob=False %}
|
||||||
{% endif %}
|
|
||||||
{% if status and status.startswith('Build complete') and summary %}
|
|
||||||
{% include "partials/deck_summary.html" %}
|
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<div id="deck-summary" data-summary>
|
||||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||||
<h4>Deck Summary</h4>
|
<h4>Deck Summary</h4>
|
||||||
<section style="margin-top:.5rem;">
|
<section style="margin-top:.5rem;">
|
||||||
|
|
@ -55,7 +56,7 @@
|
||||||
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
||||||
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||||
</style>
|
</style>
|
||||||
<div class="list-grid">
|
<div class="list-grid"{% if virtualize %} data-virtualize="list" data-virtualize-min="90"{% endif %}>
|
||||||
{% for c in clist %}
|
{% for c in clist %}
|
||||||
{# Compute overlaps with detected deck synergies when available #}
|
{# Compute overlaps with detected deck synergies when available #}
|
||||||
{% set overlaps = [] %}
|
{% set overlaps = [] %}
|
||||||
|
|
@ -190,7 +191,13 @@
|
||||||
|
|
||||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||||
<section style="margin-top:1rem;">
|
<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 [] %}
|
{% 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;">
|
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||||
<!-- Pips Panel -->
|
<!-- Pips Panel -->
|
||||||
|
|
@ -203,28 +210,26 @@
|
||||||
{% for color in colors %}
|
{% for color in colors %}
|
||||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||||
{% set pct = (w * 100) | int %}
|
{% set pct = (w * 100) | int %}
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;" class="chart-column">
|
||||||
<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 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 pc = pd['cards'] if 'cards' in pd else None %}
|
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
{% set parts = [] %}
|
||||||
{% set parts = [] %}
|
{% for c in c_cards %}
|
||||||
{% for c in c_cards %}
|
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||||
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
{% if c.dfc %}
|
||||||
{% if c.dfc %}
|
{% set label = label ~ ' (DFC)' %}
|
||||||
{% set label = label ~ ' (DFC)' %}
|
{% endif %}
|
||||||
{% endif %}
|
{% set _ = parts.append(label) %}
|
||||||
{% set _ = parts.append(label) %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% set cards_line = parts|join(' • ') %}
|
||||||
{% set cards_line = parts|join(' • ') %}
|
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||||
{% 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"
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
{% set h = (pct * 1.0) | int %}
|
{% set h = (pct * 1.0) | int %}
|
||||||
{% set bar_h = (h if h>2 else 2) %}
|
{% set bar_h = (h if h>2 else 2) %}
|
||||||
{% set y = 118 - bar_h %}
|
{% set y = 118 - bar_h %}
|
||||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,22 +265,20 @@
|
||||||
{% for color in colors %}
|
{% for color in colors %}
|
||||||
{% set val = mg.get(color, 0) %}
|
{% set val = mg.get(color, 0) %}
|
||||||
{% set pct = (val * 100 / denom) | int %}
|
{% set pct = (val * 100 / denom) | int %}
|
||||||
<div style="text-align:center;" data-color="{{ color }}">
|
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
{% set mgc = mg['cards'] if 'cards' in mg else None %}
|
||||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
|
||||||
{% set mgc = mg['cards'] if 'cards' in mg else None %}
|
{% set parts = [] %}
|
||||||
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
|
{% for c in c_cards %}
|
||||||
{% set parts = [] %}
|
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||||
{% for c in c_cards %}
|
{% endfor %}
|
||||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
{% set cards_line = parts|join(' • ') %}
|
||||||
{% endfor %}
|
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
|
||||||
{% set cards_line = parts|join(' • ') %}
|
<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"
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
{% set bar_h = (pct if pct>2 else 2) %}
|
{% set bar_h = (pct if pct>2 else 2) %}
|
||||||
{% set y = 118 - bar_h %}
|
{% set y = 118 - bar_h %}
|
||||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -298,21 +301,19 @@
|
||||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||||
{% set val = mc.get(label, 0) %}
|
{% set val = mc.get(label, 0) %}
|
||||||
{% set pct = (val * 100 / denom) | int %}
|
{% set pct = (val * 100 / denom) | int %}
|
||||||
<div style="text-align:center;">
|
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
{% set parts = [] %}
|
||||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
{% for c in cards %}
|
||||||
{% set parts = [] %}
|
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||||
{% for c in cards %}
|
{% endfor %}
|
||||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
{% set cards_line = parts|join(' • ') %}
|
||||||
{% endfor %}
|
{% set pct_f = (100.0 * (val / denom)) %}
|
||||||
{% set cards_line = parts|join(' • ') %}
|
<div style="text-align:center;" class="chart-column">
|
||||||
{% set pct_f = (100.0 * (val / denom)) %}
|
<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"
|
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
{% set bar_h = (pct if pct>2 else 2) %}
|
{% set bar_h = (pct if pct>2 else 2) %}
|
||||||
{% set y = 118 - bar_h %}
|
{% set y = 118 - bar_h %}
|
||||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
|
||||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -324,10 +325,18 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||||
<section style="margin-top:1rem;">
|
<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
|
<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>
|
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -506,15 +515,24 @@
|
||||||
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
|
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
<style>
|
<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 */
|
/* 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; }
|
.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 */
|
/* 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; }
|
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
|
||||||
/* Ensure stack-card gets visible highlight */
|
/* 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); }
|
.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>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|
@ -532,53 +550,72 @@
|
||||||
var hoverTimer = null;
|
var hoverTimer = null;
|
||||||
var lastNames = [];
|
var lastNames = [];
|
||||||
var lastType = '';
|
var lastType = '';
|
||||||
|
var pinnedNames = [];
|
||||||
|
var pinnedType = '';
|
||||||
|
var pinnedEl = null;
|
||||||
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
|
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
|
||||||
function position(e) {
|
function position(e) {
|
||||||
tip.style.display = 'block';
|
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 vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
|
var isMobile = vw < 768;
|
||||||
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
|
|
||||||
|
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) {
|
function buildTip(el, isPinned) {
|
||||||
// Render tooltip with safe DOM and a Copy button for card list
|
// Render tooltip with safe DOM
|
||||||
tip.innerHTML = '';
|
tip.innerHTML = '';
|
||||||
var t = el.getAttribute('data-type');
|
var t = el.getAttribute('data-type');
|
||||||
var header = document.createElement('div');
|
var header = document.createElement('div');
|
||||||
header.style.fontWeight = '600';
|
header.style.fontWeight = '600';
|
||||||
header.style.marginBottom = '.25rem';
|
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 = '';
|
var listText = '';
|
||||||
if (t === 'pips') {
|
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');
|
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||||
} else if (t === 'sources') {
|
} 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');
|
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||||
} else if (t === 'curve') {
|
} 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');
|
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||||
} else {
|
} else {
|
||||||
header.textContent = el.getAttribute('aria-label') || '';
|
titleSpan.textContent = el.getAttribute('aria-label') || '';
|
||||||
}
|
}
|
||||||
tip.appendChild(header);
|
header.appendChild(titleSpan);
|
||||||
if (listText) {
|
|
||||||
var pre = document.createElement('pre');
|
// Add Copy button that works with pinned tooltips
|
||||||
pre.style.margin = '0 0 .35rem 0';
|
if (listText && isPinned) {
|
||||||
pre.style.whiteSpace = 'pre-wrap';
|
|
||||||
pre.textContent = listText;
|
|
||||||
tip.appendChild(pre);
|
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.textContent = 'Copy';
|
btn.textContent = 'Copy';
|
||||||
btn.style.fontSize = '12px';
|
btn.style.fontSize = '11px';
|
||||||
btn.style.padding = '.2rem .4rem';
|
btn.style.padding = '.15rem .35rem';
|
||||||
btn.style.border = '1px solid var(--border)';
|
btn.style.border = '1px solid var(--border)';
|
||||||
btn.style.background = '#12161c';
|
btn.style.background = '#12161c';
|
||||||
btn.style.color = '#e5e7eb';
|
btn.style.color = '#e5e7eb';
|
||||||
btn.style.borderRadius = '4px';
|
btn.style.borderRadius = '4px';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.style.flexShrink = '0';
|
||||||
btn.addEventListener('click', function(e){
|
btn.addEventListener('click', function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
|
|
@ -592,7 +629,28 @@
|
||||||
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
|
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
|
||||||
} catch(_) {}
|
} 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) {
|
function normalizeList(list) {
|
||||||
|
|
@ -605,41 +663,114 @@
|
||||||
return s.trim();
|
return s.trim();
|
||||||
}).filter(Boolean);
|
}).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() {
|
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) {
|
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);
|
position(e);
|
||||||
// Cross-highlight for mana curve bars -> card items
|
// Cross-highlight for mana curve bars -> card items
|
||||||
try {
|
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));
|
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||||
lastType = 'curve';
|
lastType = dataType;
|
||||||
highlightNames(lastNames, true);
|
// Only apply hover highlights if nothing is pinned
|
||||||
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
|
if (!pinnedEl) {
|
||||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
highlightNames(lastNames, true);
|
||||||
lastType = el.getAttribute('data-type');
|
}
|
||||||
highlightNames(lastNames, true);
|
|
||||||
}
|
}
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
});
|
});
|
||||||
el.addEventListener('mousemove', position);
|
|
||||||
|
el.addEventListener('mousemove', function(e) {
|
||||||
|
if (pinnedEl === el) return;
|
||||||
|
position(e);
|
||||||
|
});
|
||||||
|
|
||||||
el.addEventListener('mouseleave', function() {
|
el.addEventListener('mouseleave', function() {
|
||||||
|
// Don't hide if pinned
|
||||||
|
if (pinnedEl) return;
|
||||||
|
|
||||||
clearHoverTimer();
|
clearHoverTimer();
|
||||||
hoverTimer = setTimeout(function(){
|
hoverTimer = setTimeout(function(){
|
||||||
tip.style.display = 'none';
|
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 = '';
|
lastNames = []; lastType = '';
|
||||||
}, 200);
|
}, 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(){
|
tip.addEventListener('mouseleave', function(){
|
||||||
|
// Don't hide if pinned
|
||||||
|
if (pinnedEl) return;
|
||||||
tip.style.display = 'none';
|
tip.style.display = 'none';
|
||||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||||
lastNames = []; lastType = '';
|
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
|
// Initialize Show C toggle
|
||||||
initShowCToggle();
|
initShowCToggle();
|
||||||
}
|
}
|
||||||
|
|
@ -663,9 +794,9 @@
|
||||||
}
|
}
|
||||||
function highlightNames(names, on){
|
function highlightNames(names, on){
|
||||||
if (!Array.isArray(names) || names.length === 0) return;
|
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 {
|
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');
|
var n = it.getAttribute('data-card-name');
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
var match = names.indexOf(n) !== -1;
|
var match = names.indexOf(n) !== -1;
|
||||||
|
|
@ -695,4 +826,5 @@
|
||||||
attach();
|
attach();
|
||||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</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_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_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 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