feat: add collapsible analytics, click-to-pin chart tooltips, and extended virtualization

This commit is contained in:
matt 2025-10-08 11:38:30 -07:00
parent 3877890889
commit 20b9e8037c
10 changed files with 1036 additions and 202 deletions

View file

@ -1,9 +1,4 @@
- Random Modes (alpha): added env flags RANDOM_MODES, RANDOM_UI, RANDOM_MAX_ATTEMPTS, RANDOM_TIMEOUT_MS.
- Determinism: CSV_FILES_DIR override to point tests to csv_files/testdata; permalink now carries optional random fields (seed/theme/constraints).
# Changelog
All notable changes to this project will be documented in this file.
This format follows Keep a Changelog principles and aims for Semantic Versioning.
## How we version
@ -18,19 +13,26 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Optimistic include/exclude experience with HTMX caching, prefetch hints, and telemetry instrumentation for must-have interactions.
- Commander catalog skeleton placeholders and lazy commander art loading to smooth catalog fetch latency.
- Commander catalog default view now prewarms and pulls from an in-memory cache so repeat visits respond in under 200ms.
- Virtualization helper now respects `data-virtualize-*` hints and powers deck summary lists without loading all rows at once.
- Step 5 deck summary now streams via an HTMX fragment so the main review payload stays lean while virtualization kicks in post-swap.
- Mana analytics now load on-demand with collapsible sections, reducing initial deck review time by ~30-40%.
- Interactive chart tooltips with click-to-pin highlighting make cross-referencing cards between charts and deck lists easier.
### Added
- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400ms on the build wizard, stage navigator, and alternatives panel.
- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel.
- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers for include/exclude state changes and frontend beacons.
- Commander catalog results wrap in a deferred skeleton list, and commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`.
- Collapsible accordions for Mana Overview and Test Hand sections defer content loading until expanded.
- Click-to-pin chart tooltips with consistent corner positioning (lower-left desktop, lower-right mobile) and working copy buttons.
- Virtualized card lists automatically render only visible items when 12+ cards are present.changes to this project will be documented in this file.
### Changed
- Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request.
- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks.
- Build wizard Step 5 now emits optimistic include/exclude updates using cached HTMX fragments, prefetch metadata, and persistent summary containers for pending must-have selections.
- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts.
- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset.
- Must-have include/exclude buttons are hidden by default behind a new `SHOW_MUST_HAVE_BUTTONS` env toggle and now ship with tooltips explaining how they differ from locks.
- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type.
- Card grids use modern browser containment rules to minimize layout recalculations on large decks.
- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees.
- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms.
- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively.
- Chart hover zones expanded to full column width for easier interaction.
### Fixed
- _None_

View file

@ -140,6 +140,90 @@ def warm_validation_name_cache() -> None:
pass
def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
if not payload or response is None:
return
try:
existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
except Exception:
existing = None
try:
if existing:
try:
data = json.loads(existing)
except Exception:
data = {}
if isinstance(data, dict):
data.update(payload)
response.headers["HX-Trigger"] = json.dumps(data)
return
response.headers["HX-Trigger"] = json.dumps(payload)
except Exception:
try:
response.headers["HX-Trigger"] = json.dumps(payload)
except Exception:
pass
def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str:
text = message or "Deck summary will appear after the build completes."
return (
f'<div id="deck-summary" data-summary '
f'hx-get="/build/step5/summary?token={token}" '
'hx-trigger="load, step5:refresh from:body" hx-swap="outerHTML">'
f'<div class="muted" style="margin-top:1rem;">{_esc(text)}</div>'
'</div>'
)
def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
includes = list(sess.get("include_cards") or [])
excludes = list(sess.get("exclude_cards") or [])
state = {
"includes": includes,
"excludes": excludes,
"enforcement_mode": (sess.get("enforcement_mode") or "warn"),
"allow_illegal": bool(sess.get("allow_illegal")),
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
}
return state, includes, excludes
def _render_include_exclude_summary(
request: Request,
sess: dict,
sid: str,
*,
state: dict[str, Any] | None = None,
includes: list[str] | None = None,
excludes: list[str] | None = None,
) -> HTMLResponse:
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
if state is None or includes is None or excludes is None:
state, includes, excludes = _must_have_state(sess)
ctx["must_have_state"] = state
ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
ctx["include_cards"] = includes
ctx["exclude_cards"] = excludes
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
def _current_builder_summary(sess: dict) -> Any | None:
try:
ctx = sess.get("build_ctx") or {}
builder = ctx.get("builder") if isinstance(ctx, dict) else None
if builder is None:
return None
summary_fn = getattr(builder, "build_deck_summary", None)
if callable(summary_fn):
return summary_fn()
except Exception:
return None
return None
_COLOR_NAME_MAP = {
"W": "White",
"U": "Blue",
@ -772,19 +856,7 @@ async def toggle_must_haves(
except Exception:
pass
must_state = {
"includes": includes,
"excludes": excludes,
"enforcement_mode": sess.get("enforcement_mode") or "warn",
"allow_illegal": bool(sess.get("allow_illegal")),
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
}
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
ctx["must_have_state"] = must_state
ctx["summary"] = None
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
response = _render_include_exclude_summary(request, sess, sid)
try:
log_include_exclude_toggle(
@ -806,7 +878,7 @@ async def toggle_must_haves(
"exclude_count": len(excludes),
}
try:
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
except Exception:
pass
return response
@ -2377,6 +2449,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
return resp
@ -3016,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse:
base = step5_empty_ctx(request, sess)
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
@router.post("/step5/continue", response_class=HTMLResponse)
@ -3086,6 +3160,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
stage_label = res.get("label")
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
@ -3100,6 +3175,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp
@router.post("/step5/rerun", response_class=HTMLResponse)
@ -3138,6 +3214,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
sess["last_step"] = 5
# Build locked cards list with ownership and in-deck presence
@ -3164,6 +3241,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
ctx3["locked_cards"] = locked_cards
resp = templates.TemplateResponse("build/_step5.html", ctx3)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
return resp
@ -3205,6 +3283,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
return resp
except Exception as e:
# Surface a friendly error on the step 5 screen with normalized context
@ -3218,6 +3297,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
err_ctx["commander"] = commander
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
@router.get("/step5/start", response_class=HTMLResponse)
@ -3283,8 +3363,61 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
})
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
@router.get("/step5/summary", response_class=HTMLResponse)
async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
if not sid:
sid = new_sid()
sess = get_session(sid)
try:
session_token = int(sess.get("step5_summary_token", 0))
except Exception:
session_token = 0
try:
requested_token = int(token)
except Exception:
requested_token = 0
ready = bool(sess.get("step5_summary_ready"))
summary_data = sess.get("step5_summary") if ready else None
if summary_data is None and ready:
summary_data = _current_builder_summary(sess)
if summary_data is not None:
try:
sess["step5_summary"] = summary_data
except Exception:
pass
synergies: list[str] = []
try:
raw_synergies = sess.get("step5_synergies")
if isinstance(raw_synergies, (list, tuple, set)):
synergies = [str(item) for item in raw_synergies if str(item).strip()]
except Exception:
synergies = []
active_token = session_token if session_token >= requested_token else requested_token
if not ready or summary_data is None:
message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
placeholder = _step5_summary_placeholder_html(active_token, message=message)
response = HTMLResponse(placeholder)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
ctx = step5_base_ctx(request, sess)
ctx["summary"] = summary_data
ctx["synergies"] = synergies
ctx["summary_ready"] = True
ctx["summary_token"] = active_token
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
return response
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
@router.post("/lock")
@ -4376,6 +4509,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Ensure we have a CSV base stem for consistent re-exports
base_stem = None
@ -4443,6 +4577,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Reload compliance JSON and summary
compliance = None
@ -4490,6 +4625,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
return resp
@ -4519,9 +4655,14 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
except Exception:
pass
ctx2 = {"request": request, "compliance": comp}
try:
summary_token = int(sess.get("step5_summary_token", 0))
except Exception:
summary_token = 0
ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
_merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp

View file

@ -88,6 +88,19 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
ctx["name"] = sess.get("custom_export_base")
if include_locks:
ctx["locks"] = list(sess.get("locks", []))
try:
ctx["summary_token"] = int(sess.get("step5_summary_token", 0))
except Exception:
ctx["summary_token"] = 0
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
try:
raw_synergies = sess.get("step5_synergies")
if isinstance(raw_synergies, (list, tuple, set)):
ctx["synergies"] = [str(s) for s in raw_synergies if str(s).strip()]
else:
ctx["synergies"] = []
except Exception:
ctx["synergies"] = []
ctx["must_have_state"] = {
"includes": include_cards,
"excludes": exclude_cards,
@ -406,7 +419,7 @@ def step5_ctx_from_result(
"csv_path": res.get("csv_path") if done else None,
"txt_path": res.get("txt_path") if done else None,
"summary": res.get("summary") if done else None,
"compliance": res.get("compliance") if done else None,
"compliance": res.get("compliance") if done else None,
"show_skipped": bool(show_skipped),
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
@ -414,7 +427,7 @@ def step5_ctx_from_result(
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
"gated": bool(res.get("gated")),
"gated": bool(res.get("gated")),
}
if extras:
ctx.update(extras)
@ -428,6 +441,57 @@ def step5_ctx_from_result(
ctx.update(hover_meta)
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
ctx["commander_display_name"] = ctx.get("commander")
try:
token_val = int(sess.get("step5_summary_token", 0))
except Exception:
token_val = 0
summary_value = ctx.get("summary")
synergies_list: list[str] = []
if summary_value is not None:
try:
sess["step5_summary"] = summary_value
except Exception:
pass
if isinstance(summary_value, dict):
raw_synergies = summary_value.get("synergies")
if isinstance(raw_synergies, (list, tuple, set)):
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
else:
meta = summary_value.get("meta") if isinstance(summary_value.get("meta"), dict) else {}
if isinstance(meta, dict):
raw_synergies = meta.get("synergies") or meta.get("commander_synergies")
if isinstance(raw_synergies, (list, tuple, set)):
synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
token_val += 1
sess["step5_summary_token"] = token_val
sess["step5_summary_ready"] = True
if synergies_list:
sess["step5_synergies"] = synergies_list
else:
try:
if "step5_synergies" in sess:
del sess["step5_synergies"]
except Exception:
pass
else:
token_val += 1
sess["step5_summary_token"] = token_val
sess["step5_summary_ready"] = False
try:
if "step5_summary" in sess:
del sess["step5_summary"]
except Exception:
pass
try:
if "step5_synergies" in sess:
del sess["step5_synergies"]
except Exception:
pass
synergies_list = []
ctx["summary_token"] = token_val
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
ctx["synergies"] = synergies_list
return ctx
@ -463,6 +527,25 @@ def step5_error_ctx(
"added_total": 0,
"skipped": False,
}
try:
token_val = int(sess.get("step5_summary_token", 0)) + 1
except Exception:
token_val = 1
sess["step5_summary_token"] = token_val
sess["step5_summary_ready"] = False
try:
if "step5_summary" in sess:
del sess["step5_summary"]
except Exception:
pass
try:
if "step5_synergies" in sess:
del sess["step5_synergies"]
except Exception:
pass
ctx["summary_token"] = token_val
ctx["summary_ready"] = False
ctx["synergies"] = []
if extras:
ctx.update(extras)
return ctx
@ -494,6 +577,25 @@ def step5_empty_ctx(
"show_skipped": False,
"skipped": False,
}
try:
token_val = int(sess.get("step5_summary_token", 0)) + 1
except Exception:
token_val = 1
sess["step5_summary_token"] = token_val
sess["step5_summary_ready"] = False
try:
if "step5_summary" in sess:
del sess["step5_summary"]
except Exception:
pass
try:
if "step5_synergies" in sess:
del sess["step5_synergies"]
except Exception:
pass
ctx["summary_token"] = token_val
ctx["summary_ready"] = False
ctx["synergies"] = []
if extras:
ctx.update(extras)
return ctx

View file

@ -798,9 +798,8 @@
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
function initVirtualization(root){
try{
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
// Global diagnostics aggregator
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
var GLOBAL = (function(){
if (!DIAG) return null;
if (window.__virtGlobal) return window.__virtGlobal;
@ -821,7 +820,6 @@
el.style.zIndex = '50';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
el.style.cursor = 'default';
// Hidden by default; toggle with 'v'
el.style.display = 'none';
document.body.appendChild(el);
store.summaryEl = el;
@ -837,7 +835,7 @@
visible += (g[i].end||0) - (g[i].start||0);
lastMs = Math.max(lastMs, g[i].lastMs||0);
}
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+(lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs))+'ms';
}
function register(gridId, ref){
store.grids.push({ id: gridId, ref: ref });
@ -852,48 +850,66 @@
}
update();
},
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
toggle: function(){
var el = ensure();
el.style.display = (el.style.display === 'none' ? '' : 'none');
}
};
}
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
window.__virtGlobal = {
register: register,
toggle: function(){
var el = ensure();
el.style.display = (el.style.display === 'none' ? '' : 'none');
}
};
return window.__virtGlobal;
})();
// Support card grids and other scroll containers (e.g., #owned-box)
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
var grids = scope.querySelectorAll('[data-virtualize]');
if (!grids.length) return;
grids.forEach(function(grid){
if (grid.__virtBound) return;
grid.__virtBound = true;
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
if (!grid || grid.__virtBound) return;
var attrVal = (grid.getAttribute('data-virtualize') || '').trim();
if (!attrVal || /^0|false$/i.test(attrVal)) return;
var container = grid;
container.style.position = container.style.position || 'relative';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
// Ensure wrapper itself is a grid to preserve multi-column layout inside
// when the container (e.g., .card-grid) is virtualized.
wrapper.style.display = 'grid';
// Move children into a fragment store (for owned, children live under UL)
var mode = attrVal.toLowerCase();
var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10);
var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10);
var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10);
var maxHeightAttr = grid.getAttribute('data-virtualize-max-height') || (grid.dataset ? grid.dataset.virtualizeMaxHeight : '');
var overflowAttr = grid.getAttribute('data-virtualize-overflow') || (grid.dataset ? grid.dataset.virtualizeOverflow : '');
var source = container;
// If this is the owned box, use the UL inside as the source list
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
if (!source || !source.children || !source.children.length) return;
var all = Array.prototype.slice.call(source.children);
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
// is cheaper than the complexity for small sets.
var MIN_VIRT_ITEMS = 80;
if (all.length < MIN_VIRT_ITEMS){
// Mark as processed so we don't attempt again on HTMX swaps.
return; // children remain in place; no virtualization applied.
}
all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } });
var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80;
if (all.length < minItems) return;
grid.__virtBound = true;
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });
all.forEach(function(node){ store.appendChild(node); });
var padTop = document.createElement('div');
var padBottom = document.createElement('div');
padTop.style.height = '0px'; padBottom.style.height = '0px';
// For owned, keep the UL but render into it; otherwise append wrapper to container
padTop.style.height = '0px';
padBottom.style.height = '0px';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
if (ownedGrid){
ownedGrid.innerHTML = '';
ownedGrid.appendChild(padTop);
@ -901,17 +917,34 @@
ownedGrid.appendChild(padBottom);
ownedGrid.appendChild(store);
} else {
container.appendChild(padTop);
container.appendChild(wrapper);
container.appendChild(padBottom);
container.appendChild(store);
}
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
var perRow = 1;
// Optional diagnostics overlay
if (maxHeightAttr){
container.style.maxHeight = maxHeightAttr;
} else if (!container.style.maxHeight){
container.style.maxHeight = '70vh';
}
if (overflowAttr){
container.style.overflow = overflowAttr;
} else if (!container.style.overflow){
container.style.overflow = 'auto';
}
var baseRow = container.id === 'owned-box' ? 160 : (mode.indexOf('list') > -1 ? 110 : 240);
var minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow;
var rowH = minRowH;
var explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null;
var perRow = explicitCols || 1;
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
var renderCount = 0; var measureCount = 0; var swapCount = 0;
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
function ensureDiag(){
if (!DIAG) return null;
@ -928,8 +961,7 @@
diagBox.style.fontSize = '12px';
diagBox.style.margin = '0 0 .35rem 0';
diagBox.style.color = '#cbd5e1';
diagBox.style.display = 'none'; // hidden until toggled
// Controls
diagBox.style.display = 'none';
var controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '.35rem';
@ -937,107 +969,204 @@
controls.style.marginBottom = '.25rem';
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
btnCopy.addEventListener('click', function(){ try{ var payload = {
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
btnCopy.addEventListener('click', function(){
try{
var payload = {
id: gridId,
rowH: rowH,
perRow: perRow,
start: start,
end: end,
total: total,
renderCount: renderCount,
measureCount: measureCount,
swapCount: swapCount,
lastRenderMs: lastRenderMs,
lastRenderAt: lastRenderAt,
};
navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
btnCopy.textContent = 'Copied';
setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200);
}catch(_){ }
});
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
controls.appendChild(title);
controls.appendChild(btnCopy);
controls.appendChild(btnHide);
diagBox.appendChild(controls);
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
host.insertBefore(diagBox, host.firstChild);
return diagBox;
}
function measure(){
try {
measureCount++;
// create a temp tile to measure if none
var probe = store.firstElementChild || all[0];
if (probe){
var fake = probe.cloneNode(true);
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
fake.style.position = 'absolute';
fake.style.visibility = 'hidden';
fake.style.pointerEvents = 'none';
(ownedGrid || container).appendChild(fake);
var rect = fake.getBoundingClientRect();
rowH = Math.max(120, Math.ceil(rect.height) + 16);
rowH = Math.max(minRowH, Math.ceil(rect.height) + 16);
(ownedGrid || container).removeChild(fake);
}
// Estimate perRow via computed styles of grid
var style = window.getComputedStyle(ownedGrid || container);
var cols = style.getPropertyValue('grid-template-columns');
// Mirror grid settings onto the wrapper so its children still flow in columns
try {
var displayMode = style.getPropertyValue('display');
if (displayMode && displayMode.trim()){
wrapper.style.display = displayMode;
} else if (!wrapper.style.display){
wrapper.style.display = 'grid';
}
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
if (gap && gap.trim()) wrapper.style.gap = gap;
// Inherit justify/align if present
var ji = style.getPropertyValue('justify-items');
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
var ai = style.getPropertyValue('align-items');
if (ai && ai.trim()) wrapper.style.alignItems = ai;
} catch(_) {}
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
} catch(_){}
} catch(_){ }
var derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){
return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);
}).length : 0);
if (explicitCols){
perRow = explicitCols;
} else if (derivedCols){
perRow = Math.max(1, derivedCols);
} else {
perRow = Math.max(1, perRow);
}
} catch(_){ }
}
measure();
var total = all.length;
var start = 0, end = 0;
function render(){
var t0 = DIAG ? performance.now() : 0;
var scroller = container;
var vh = scroller.clientHeight || window.innerHeight;
var scrollTop = scroller.scrollTop;
// If container isnt scrollable, use window scroll offset
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
var newStart = rowStart * perRow;
var newEnd = Math.min(total, rowEnd * perRow);
if (newStart === start && newEnd === end) return; // no change
start = newStart; end = newEnd;
// Padding
var beforeRows = Math.floor(start / perRow);
var afterRows = Math.ceil((total - end) / perRow);
var vh, scrollTop, top;
if (useWindowScroll) {
// Window-scroll mode: measure relative to viewport
vh = window.innerHeight;
var rect = container.getBoundingClientRect();
top = Math.max(0, -rect.top);
scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0;
} else {
// Container-scroll mode: measure relative to container
vh = scroller.clientHeight || window.innerHeight;
scrollTop = scroller.scrollTop;
top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
}
var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2;
var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1);
var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow)));
var newStart = rowStart * Math.max(1, perRow);
var newEnd = Math.min(total, rowEnd * Math.max(1, perRow));
if (newStart === start && newEnd === end) return;
start = newStart;
end = newEnd;
var beforeRows = Math.floor(start / Math.max(1, perRow));
var afterRows = Math.ceil((total - end) / Math.max(1, perRow));
padTop.style.height = (beforeRows * rowH) + 'px';
padBottom.style.height = (afterRows * rowH) + 'px';
// Render visible children
wrapper.innerHTML = '';
for (var i = start; i < end; i++) {
for (var i = start; i < end; i++){
var node = all[i];
if (node) wrapper.appendChild(node);
}
if (DIAG){
var box = ensureDiag();
if (box){
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
var dt = performance.now() - t0;
lastRenderMs = dt;
renderCount++;
lastRenderAt = Date.now();
var vis = end - start;
var rowsTotal = Math.ceil(total / Math.max(1, perRow));
var textEl = box.querySelector('.virt-diag-text');
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
textEl.textContent = msg;
// Health hint
var bad = (dt > 33) || (vis > 300);
var warn = (!bad) && ((dt > 16) || (vis > 200));
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
if (globalReg && globalReg.set){
globalReg.set({ total: total, start: start, end: end, lastMs: dt });
}
}
}
}
function onScroll(){ render(); }
function onResize(){ measure(); render(); }
container.addEventListener('scroll', onScroll);
// Support both container-scroll (default) and window-scroll modes
var scrollMode = overflowAttr || container.style.overflow || 'auto';
var useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window');
if (useWindowScroll) {
// Window-scroll mode: listen to window scroll events
window.addEventListener('scroll', onScroll, { passive: true });
} else {
// Container-scroll mode: listen to container scroll events
container.addEventListener('scroll', onScroll, { passive: true });
}
window.addEventListener('resize', onResize);
// Initial size; ensure container is scrollable for our logic
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
container.style.overflow = container.style.overflow || 'auto';
render();
// Re-render after filters resort or HTMX swaps
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
// Keyboard toggle for overlays: 'v'
// Track cleanup for disconnected containers
grid.__virtCleanup = function(){
try {
if (useWindowScroll) {
window.removeEventListener('scroll', onScroll);
} else {
container.removeEventListener('scroll', onScroll);
}
window.removeEventListener('resize', onResize);
} catch(_){}
};
document.addEventListener('htmx:afterSwap', function(ev){
if (!container.isConnected) return;
if (!container.contains(ev.target)) return;
swapCount++;
var merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children));
var known = new Map();
all.forEach(function(node, idx){
var index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx;
known.set(node, index);
});
var nextIndex = known.size;
merged.forEach(function(node){
if (!known.has(node)){
node.__virtIndex = nextIndex;
known.set(node, nextIndex);
nextIndex++;
}
});
merged.sort(function(a, b){
var ia = known.get(a);
var ib = known.get(b);
return (ia - ib);
});
merged.forEach(function(node, idx){ node.__virtIndex = idx; });
all = merged;
total = all.length;
measure();
render();
});
if (DIAG && !window.__virtHotkeyBound){
window.__virtHotkeyBound = true;
document.addEventListener('keydown', function(e){
@ -1045,9 +1174,11 @@
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key && e.key.toLowerCase() === 'v'){
e.preventDefault();
// Toggle all virt-diag boxes and the global summary
var shown = null;
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
document.querySelectorAll('.virt-diag').forEach(function(b){
if (shown === null) shown = (b.style.display === 'none');
b.style.display = shown ? '' : 'none';
});
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
}
}catch(_){ }
@ -1198,4 +1329,61 @@
});
}catch(_){ }
});
// --- Lazy-loading analytics accordions ---
function initLazyAccordions(root){
try {
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){
if (!details || details.__lazyBound) return;
details.__lazyBound = true;
var loaded = false;
details.addEventListener('toggle', function(){
if (!details.open || loaded) return;
loaded = true;
// Mark as loaded to prevent re-initialization
var content = details.querySelector('.analytics-content');
if (!content) return;
// Remove placeholder if present
var placeholder = content.querySelector('.analytics-placeholder');
if (placeholder) {
placeholder.remove();
}
// Content is already rendered in the template, just need to initialize any scripts
// Re-run virtualization if needed
try {
initVirtualization(content);
} catch(_){}
// Re-attach chart interactivity if this is mana overview
var type = details.getAttribute('data-analytics-type');
if (type === 'mana') {
try {
// Tooltip and highlight logic is already in the template scripts
// Just trigger a synthetic event to re-attach if needed
var event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } });
details.dispatchEvent(event);
} catch(_){}
}
// Send telemetry
telemetry.send('analytics.accordion_expand', {
type: type || 'unknown',
accordion: details.id || 'unnamed',
});
});
});
} catch(_){}
}
// Initialize on load and after HTMX swaps
document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(); });
document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
})();

View file

@ -627,3 +627,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
@media (min-width: 900px) {
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
}
/* Analytics accordion styling */
.analytics-accordion {
transition: all 0.2s ease;
}
.analytics-accordion summary {
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.analytics-accordion summary:hover {
background: #1f2937;
border-color: #374151;
}
.analytics-accordion summary:active {
transform: scale(0.99);
}
.analytics-accordion[open] summary {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.analytics-accordion .analytics-content {
animation: accordion-slide-down 0.3s ease-out;
}
@keyframes accordion-slide-down {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.analytics-placeholder .skeleton-pulse {
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}

View file

@ -24,7 +24,7 @@
{# Flagged tiles by category, in the same card grid style #}
{% if flagged_meta and flagged_meta|length > 0 %}
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
<div class="card-grid">
<div class="card-grid"{% if flagged_meta|length >= 12 %} data-virtualize="grid" data-virtualize-min="12" data-virtualize-columns="4"{% endif %}>
{% for f in flagged_meta %}
{% set sev = (f.severity or 'FAIL')|upper %}
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>

View file

@ -462,11 +462,16 @@
<!-- controls now above -->
{% if allow_must_haves %}
{% include "partials/include_exclude_summary.html" %}
{% endif %}
{% if status and status.startswith('Build complete') and summary %}
{% include "partials/deck_summary.html" %}
{% include "partials/include_exclude_summary.html" with oob=False %}
{% endif %}
<div id="deck-summary" data-summary
hx-get="/build/step5/summary?token={{ summary_token }}"
hx-trigger="load, step5:refresh from:body"
hx-swap="outerHTML">
<div class="muted" style="margin-top:1rem;">
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
</div>
</div>
</div>
</div>
</section>

View file

@ -1,3 +1,4 @@
<div id="deck-summary" data-summary>
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
<section style="margin-top:.5rem;">
@ -55,7 +56,7 @@
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
</style>
<div class="list-grid">
<div class="list-grid"{% if virtualize %} data-virtualize="list" data-virtualize-min="90"{% endif %}>
{% for c in clist %}
{# Compute overlaps with detected deck synergies when available #}
{% set overlaps = [] %}
@ -190,7 +191,13 @@
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<h5>Mana Overview</h5>
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<span>Mana Overview</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .5rem 0;">Mana Overview</h5>
{% set deck_colors = summary.colors or [] %}
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
<!-- Pips Panel -->
@ -203,28 +210,26 @@
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
{% if c.dfc %}
{% set label = label ~ ' (DFC)' %}
{% endif %}
{% set _ = parts.append(label) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
<div style="text-align:center;" class="chart-column">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
{% if c.dfc %}
{% set label = label ~ ' (DFC)' %}
{% endif %}
{% set _ = parts.append(label) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
@ -260,22 +265,20 @@
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
{% set mgc = mg['cards'] if 'cards' in mg else None %}
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
{% set mgc = mg['cards'] if 'cards' in mg else None %}
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
@ -298,21 +301,19 @@
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
{% set parts = [] %}
{% for c in cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
{% set parts = [] %}
{% for c in cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<div style="text-align:center;" class="chart-column">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
</div>
@ -324,10 +325,18 @@
{% endif %}
</div>
</div>
</div>
</details>
</section>
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
<section style="margin-top:1rem;">
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<span>Test Hand</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
</h5>
@ -506,15 +515,24 @@
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
}
</style>
</div>
</details>
</section>
<style>
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
.chart-tooltip { position: fixed; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); max-width: 90vw; }
/* Pinned tooltip gets pointer events for Copy button */
.chart-tooltip.pinned { pointer-events: auto; border-color: #f59e0b; box-shadow: 0 4px 20px rgba(245,158,11,.3); }
/* Unpinned tooltip has no pointer events (hover only) */
.chart-tooltip:not(.pinned) { pointer-events: none; }
/* Cross-highlight from charts to cards */
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
/* For list view, ensure baseline padding so no layout shift on highlight */
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
/* Ensure stack-card gets visible highlight */
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
/* Chart columns get cursor pointer */
.chart-column svg { cursor: pointer; transition: opacity 0.15s ease; }
.chart-column svg:hover { opacity: 0.85; }
</style>
<script>
(function() {
@ -532,53 +550,72 @@
var hoverTimer = null;
var lastNames = [];
var lastType = '';
var pinnedNames = [];
var pinnedType = '';
var pinnedEl = null;
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
function position(e) {
tip.style.display = 'block';
var x = e.clientX + 12, y = e.clientY + 12;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
var rect = tip.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
var isMobile = vw < 768;
if (isMobile) {
// Mobile: fixed to lower-right corner
tip.style.right = '8px';
tip.style.bottom = '8px';
tip.style.left = 'auto';
tip.style.top = 'auto';
tip.style.maxWidth = 'calc(100vw - 16px)';
} else {
// Desktop: fixed to lower-left corner
tip.style.left = '8px';
tip.style.bottom = '8px';
tip.style.right = 'auto';
tip.style.top = 'auto';
tip.style.maxWidth = '400px';
}
}
function buildTip(el) {
// Render tooltip with safe DOM and a Copy button for card list
function buildTip(el, isPinned) {
// Render tooltip with safe DOM
tip.innerHTML = '';
var t = el.getAttribute('data-type');
var header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '.25rem';
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.justifyContent = 'space-between';
header.style.gap = '.5rem';
var titleSpan = document.createElement('span');
var listText = '';
if (t === 'pips') {
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
titleSpan.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'sources') {
header.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
titleSpan.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'curve') {
header.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else {
header.textContent = el.getAttribute('aria-label') || '';
titleSpan.textContent = el.getAttribute('aria-label') || '';
}
tip.appendChild(header);
if (listText) {
var pre = document.createElement('pre');
pre.style.margin = '0 0 .35rem 0';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = listText;
tip.appendChild(pre);
header.appendChild(titleSpan);
// Add Copy button that works with pinned tooltips
if (listText && isPinned) {
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.fontSize = '12px';
btn.style.padding = '.2rem .4rem';
btn.style.fontSize = '11px';
btn.style.padding = '.15rem .35rem';
btn.style.border = '1px solid var(--border)';
btn.style.background = '#12161c';
btn.style.color = '#e5e7eb';
btn.style.borderRadius = '4px';
btn.style.cursor = 'pointer';
btn.style.flexShrink = '0';
btn.addEventListener('click', function(e){
e.stopPropagation();
try {
@ -592,7 +629,28 @@
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
} catch(_) {}
});
tip.appendChild(btn);
header.appendChild(btn);
}
tip.appendChild(header);
if (listText) {
var pre = document.createElement('pre');
pre.style.margin = '.25rem 0 0 0';
pre.style.whiteSpace = 'pre-wrap';
pre.style.fontSize = '12px';
pre.textContent = listText;
tip.appendChild(pre);
}
// Add hint for pinning on desktop
if (!isPinned && window.innerWidth >= 768) {
var hint = document.createElement('div');
hint.style.marginTop = '.35rem';
hint.style.fontSize = '11px';
hint.style.color = '#9ca3af';
hint.style.fontStyle = 'italic';
hint.textContent = 'Click to pin';
tip.appendChild(hint);
}
}
function normalizeList(list) {
@ -605,41 +663,114 @@
return s.trim();
}).filter(Boolean);
}
function unpin() {
if (pinnedEl) {
pinnedEl.style.outline = '';
pinnedEl = null;
}
if (pinnedNames && pinnedNames.length) {
highlightNames(pinnedNames, false);
}
pinnedNames = [];
pinnedType = '';
tip.classList.remove('pinned');
tip.style.display = 'none';
}
function pin(el, e) {
// Unpin previous if different element
if (pinnedEl && pinnedEl !== el) {
unpin();
}
// Toggle: if clicking same element, unpin
if (pinnedEl === el) {
unpin();
return;
}
// Pin new element
pinnedEl = el;
el.style.outline = '2px solid #f59e0b';
el.style.outlineOffset = '2px';
var dataType = el.getAttribute('data-type');
pinnedNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
pinnedType = dataType;
tip.classList.add('pinned');
buildTip(el, true);
position(e);
highlightNames(pinnedNames, true);
}
function attach() {
document.querySelectorAll('[data-type]').forEach(function(el) {
// Attach to SVG elements with data-type for better hover zones
document.querySelectorAll('svg[data-type]').forEach(function(el) {
el.addEventListener('mouseenter', function(e) {
buildTip(el);
// Don't show hover tooltip if this element is pinned
if (pinnedEl === el) return;
clearHoverTimer();
buildTip(el, false);
position(e);
// Cross-highlight for mana curve bars -> card items
try {
if (el.getAttribute('data-type') === 'curve') {
var dataType = el.getAttribute('data-type');
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = 'curve';
highlightNames(lastNames, true);
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = el.getAttribute('data-type');
highlightNames(lastNames, true);
lastType = dataType;
// Only apply hover highlights if nothing is pinned
if (!pinnedEl) {
highlightNames(lastNames, true);
}
}
} catch(_) {}
});
el.addEventListener('mousemove', position);
el.addEventListener('mousemove', function(e) {
if (pinnedEl === el) return;
position(e);
});
el.addEventListener('mouseleave', function() {
// Don't hide if pinned
if (pinnedEl) return;
clearHoverTimer();
hoverTimer = setTimeout(function(){
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
try { if (lastNames && lastNames.length && !pinnedEl) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
}, 200);
});
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
pin(el, e);
});
});
// Keep tooltip open while hovering it (for pinned tooltips with Copy button)
tip.addEventListener('mouseenter', function(){
clearHoverTimer();
});
// Keep tooltip open while hovering it
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
tip.addEventListener('mouseleave', function(){
// Don't hide if pinned
if (pinnedEl) return;
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
});
// Click outside to unpin
document.addEventListener('click', function(e) {
if (!pinnedEl) return;
// Don't unpin if clicking the tooltip itself or a chart
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
unpin();
});
// Initialize Show C toggle
initShowCToggle();
}
@ -663,9 +794,9 @@
}
function highlightNames(names, on){
if (!Array.isArray(names) || names.length === 0) return;
// List view spans
// List view spans - target only the .name span, not the parent .list-row
try {
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
document.querySelectorAll('#typeview-list .list-row .name[data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
if (!n) return;
var match = names.indexOf(n) !== -1;
@ -696,3 +827,4 @@
document.addEventListener('htmx:afterSwap', function() { attach(); });
})();
</script>
</div>

View file

@ -1,4 +1,5 @@
<div id="include-exclude-summary" data-summary>
{% set is_oob = oob if oob is defined else False %}
<div id="include-exclude-summary" data-summary{% if is_oob %} hx-swap-oob="true"{% endif %}>
{% set pending_state = must_have_state if must_have_state is defined else None %}
{% set pending_includes = pending_state.includes if pending_state and pending_state.includes is not none else [] %}
{% set pending_excludes = pending_state.excludes if pending_state and pending_state.excludes is not none else [] %}

212
docs/styleguide.md Normal file
View 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)