diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bfbe8..71cde8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,4 @@ -- Random Modes (alpha): added env flags RANDOM_MODES, RANDOM_UI, RANDOM_MAX_ATTEMPTS, RANDOM_TIMEOUT_MS. -- Determinism: CSV_FILES_DIR override to point tests to csv_files/testdata; permalink now carries optional random fields (seed/theme/constraints). # Changelog - -All notable changes to this project will be documented in this file. - This format follows Keep a Changelog principles and aims for Semantic Versioning. ## How we version @@ -18,19 +13,26 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Optimistic include/exclude experience with HTMX caching, prefetch hints, and telemetry instrumentation for must-have interactions. - Commander catalog skeleton placeholders and lazy commander art loading to smooth catalog fetch latency. - Commander catalog default view now prewarms and pulls from an in-memory cache so repeat visits respond in under 200 ms. +- Virtualization helper now respects `data-virtualize-*` hints and powers deck summary lists without loading all rows at once. +- Step 5 deck summary now streams via an HTMX fragment so the main review payload stays lean while virtualization kicks in post-swap. +- Mana analytics now load on-demand with collapsible sections, reducing initial deck review time by ~30-40%. +- Interactive chart tooltips with click-to-pin highlighting make cross-referencing cards between charts and deck lists easier. ### Added -- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel. +- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel. - Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers for include/exclude state changes and frontend beacons. - Commander catalog results wrap in a deferred skeleton list, and commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`. +- Collapsible accordions for Mana Overview and Test Hand sections defer content loading until expanded. +- Click-to-pin chart tooltips with consistent corner positioning (lower-left desktop, lower-right mobile) and working copy buttons. +- Virtualized card lists automatically render only visible items when 12+ cards are present.changes to this project will be documented in this file. ### Changed -- Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request. -- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks. -- Build wizard Step 5 now emits optimistic include/exclude updates using cached HTMX fragments, prefetch metadata, and persistent summary containers for pending must-have selections. -- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts. -- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset. -- Must-have include/exclude buttons are hidden by default behind a new `SHOW_MUST_HAVE_BUTTONS` env toggle and now ship with tooltips explaining how they differ from locks. +- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type. +- Card grids use modern browser containment rules to minimize layout recalculations on large decks. +- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees. +- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms. +- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively. +- Chart hover zones expanded to full column width for easier interaction. ### Fixed - _None_ diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 254bd04..e058ed6 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -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'
' + f'
{_esc(text)}
' + '
' + ) + + +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 diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index fc1f83e..ee97e43 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -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 diff --git a/code/web/static/app.js b/code/web/static/app.js index 022607c..fa1e2ad 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -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 isn’t scrollable, use window scroll offset - var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); - var rowsInView = Math.ceil(vh / rowH) + 2; // overscan - var rowStart = Math.max(0, Math.floor(top / rowH) - 1); - var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow)); - var newStart = rowStart * perRow; - var newEnd = Math.min(total, rowEnd * perRow); - if (newStart === start && newEnd === end) return; // no change - start = newStart; end = newEnd; - // Padding - var beforeRows = Math.floor(start / perRow); - var afterRows = Math.ceil((total - end) / perRow); + var vh, scrollTop, top; + + if (useWindowScroll) { + // Window-scroll mode: measure relative to viewport + vh = window.innerHeight; + var rect = container.getBoundingClientRect(); + top = Math.max(0, -rect.top); + scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + } else { + // Container-scroll mode: measure relative to container + vh = scroller.clientHeight || window.innerHeight; + scrollTop = scroller.scrollTop; + top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); + } + + var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2; + var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1); + var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow))); + var newStart = rowStart * Math.max(1, perRow); + var newEnd = Math.min(total, rowEnd * Math.max(1, perRow)); + if (newStart === start && newEnd === end) return; + start = newStart; + end = newEnd; + var beforeRows = Math.floor(start / Math.max(1, perRow)); + var afterRows = Math.ceil((total - end) / Math.max(1, perRow)); padTop.style.height = (beforeRows * rowH) + 'px'; padBottom.style.height = (afterRows * rowH) + 'px'; - // Render visible children wrapper.innerHTML = ''; - for (var i = start; i < end; i++) { + 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); }); })(); diff --git a/code/web/static/styles.css b/code/web/static/styles.css index d0979b3..6992feb 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -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; } +} diff --git a/code/web/templates/build/_compliance_panel.html b/code/web/templates/build/_compliance_panel.html index 96890d7..e1d9f66 100644 --- a/code/web/templates/build/_compliance_panel.html +++ b/code/web/templates/build/_compliance_panel.html @@ -24,7 +24,7 @@ {# Flagged tiles by category, in the same card grid style #} {% if flagged_meta and flagged_meta|length > 0 %}
Flagged cards
-
+
= 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 %}
diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index cd36df4..9757ff0 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -462,11 +462,16 @@ {% 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 %} +
+
+ {% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %} +
+
diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index 30c94f7..e327bef 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -1,3 +1,4 @@ +

Deck Summary

@@ -55,7 +56,7 @@ .dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; } .dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; } -
+
{% for c in clist %} {# Compute overlaps with detected deck synergies when available #} {% set overlaps = [] %} @@ -190,7 +191,13 @@
-
Mana Overview
+
+ + Mana Overview + (pips • sources • curve) + +
+
Mana Overview
{% set deck_colors = summary.colors or [] %}
@@ -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 %} -
- - {% 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 %} - +
+ {% 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 %} + + {% set h = (pct * 1.0) | int %} {% set bar_h = (h if h>2 else 2) %} {% set y = 118 - bar_h %} - +
{{ color }}
@@ -260,22 +265,20 @@ {% for color in colors %} {% set val = mg.get(color, 0) %} {% set pct = (val * 100 / denom) | int %} -
- - {% 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(' • ') %} - + {% 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(' • ') %} +
+ + {% set bar_h = (pct if pct>2 else 2) %} {% set y = 118 - bar_h %} - +
{{ color }}
@@ -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 %} -
- - {% 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)) %} - + {% 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)) %} +
+ + {% set bar_h = (pct if pct>2 else 2) %} {% set y = 118 - bar_h %} - +
{{ label }}
@@ -324,10 +325,18 @@ {% endif %}
+
+
+
+ + Test Hand + (draw 7 random cards) + +
Test Hand Draw 7 at random (no repeats except for basic lands).
@@ -506,15 +515,24 @@ #test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; } } +
+
\ No newline at end of file + +
\ No newline at end of file diff --git a/code/web/templates/partials/include_exclude_summary.html b/code/web/templates/partials/include_exclude_summary.html index d5a2245..6a12595 100644 --- a/code/web/templates/partials/include_exclude_summary.html +++ b/code/web/templates/partials/include_exclude_summary.html @@ -1,4 +1,5 @@ -
+{% set is_oob = oob if oob is defined else False %} +
{% 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 [] %} diff --git a/docs/styleguide.md b/docs/styleguide.md new file mode 100644 index 0000000..8504040 --- /dev/null +++ b/docs/styleguide.md @@ -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 + + + Label + +``` + +**Chip containers:** +```html + +
+ Tag 1 + Tag 2 +
+ + +
+ Item 1 + Item 2 + Item 3 +
+ + +
...
+ + +
...
+``` + +### Summary Panels + +Responsive grid panels for dashboard-style layouts: + +```html +
+
+
Panel Title
+
+ Panel content here +
+
+
+
Another Panel
+
+ More content +
+
+
+``` + +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)