diff --git a/.env.example b/.env.example
index 6827b32..38e83f2 100644
--- a/.env.example
+++ b/.env.example
@@ -43,6 +43,7 @@ ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
+SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons)
WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only)
############################
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4f21bf..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
@@ -14,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Summary
-- _No changes yet_
+- Responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists.
+- Optimistic include/exclude experience with HTMX caching, prefetch hints, and telemetry instrumentation for must-have interactions.
+- Commander catalog skeleton placeholders and lazy commander art loading to smooth catalog fetch latency.
+- Commander catalog default view now prewarms and pulls from an in-memory cache so repeat visits respond in under 200 ms.
+- Virtualization helper now respects `data-virtualize-*` hints and powers deck summary lists without loading all rows at once.
+- Step 5 deck summary now streams via an HTMX fragment so the main review payload stays lean while virtualization kicks in post-swap.
+- Mana analytics now load on-demand with collapsible sections, reducing initial deck review time by ~30-40%.
+- Interactive chart tooltips with click-to-pin highlighting make cross-referencing cards between charts and deck lists easier.
### Added
-- _None_
+- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel.
+- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers for include/exclude state changes and frontend beacons.
+- Commander catalog results wrap in a deferred skeleton list, and commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`.
+- Collapsible accordions for Mana Overview and Test Hand sections defer content loading until expanded.
+- Click-to-pin chart tooltips with consistent corner positioning (lower-left desktop, lower-right mobile) and working copy buttons.
+- Virtualized card lists automatically render only visible items when 12+ cards are present.changes to this project will be documented in this file.
### Changed
-- _None_
+- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type.
+- Card grids use modern browser containment rules to minimize layout recalculations on large decks.
+- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees.
+- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms.
+- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively.
+- Chart hover zones expanded to full column width for easier interaction.
### Fixed
- _None_
diff --git a/DOCKER.md b/DOCKER.md
index 6b5185b..9ac2c7b 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -252,6 +252,7 @@ See `.env.example` for the full catalog. Common knobs:
| `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. |
| `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. |
| `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. |
+| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
### Random build controls
diff --git a/README.md b/README.md
index f4fb612..ee62409 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. |
| `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. |
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
+| `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |
| `THEME` | `dark` | Default UI theme (`system`, `light`, or `dark`). |
### Random build tuning
diff --git a/code/web/app.py b/code/web/app.py
index b11b393..afdfc49 100644
--- a/code/web/app.py
+++ b/code/web/app.py
@@ -19,7 +19,8 @@ from contextlib import asynccontextmanager
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics
from tagging.multi_face_merger import load_merge_summary
from .services.combo_utils import detect_all as _detect_all
-from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
+from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore
+from .services.commander_catalog_loader import load_commander_catalog # type: ignore
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
# Resolve template/static dirs relative to this file
@@ -42,6 +43,19 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
prewarm_common_filters()
except Exception:
pass
+ # Warm commander + theme catalogs so the first commander catalog request skips disk reads
+ try:
+ load_commander_catalog()
+ except Exception:
+ pass
+ try:
+ load_index()
+ except Exception:
+ pass
+ try:
+ commanders_routes.prewarm_default_page() # type: ignore[attr-defined]
+ except Exception:
+ pass
# Warm preview card index once (updated Phase A: moved to card_index module)
try: # local import to avoid cost if preview unused
from .services.card_index import maybe_build_index # type: ignore
@@ -112,6 +126,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
+SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True)
ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True)
@@ -251,6 +266,7 @@ templates.env.globals.update({
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
"enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS,
"allow_must_haves": ALLOW_MUST_HAVES,
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"default_theme": DEFAULT_THEME,
"random_modes": RANDOM_MODES,
"random_ui": RANDOM_UI,
@@ -840,6 +856,7 @@ async def status_sys():
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
"ENABLE_PARTNER_MECHANICS": bool(ENABLE_PARTNER_MECHANICS),
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
+ "SHOW_MUST_HAVE_BUTTONS": bool(SHOW_MUST_HAVE_BUTTONS),
"DEFAULT_THEME": DEFAULT_THEME,
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
"USER_THEME_LIMIT": int(USER_THEME_LIMIT),
@@ -2186,6 +2203,7 @@ from .routes import owned as owned_routes # noqa: E402
from .routes import themes as themes_routes # noqa: E402
from .routes import commanders as commanders_routes # noqa: E402
from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402
+from .routes import telemetry as telemetry_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
@@ -2194,6 +2212,7 @@ app.include_router(owned_routes.router)
app.include_router(themes_routes.router)
app.include_router(commanders_routes.router)
app.include_router(partner_suggestions_routes.router)
+app.include_router(telemetry_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev
try:
diff --git a/code/web/routes/build.py b/code/web/routes/build.py
index f24b36f..e058ed6 100644
--- a/code/web/routes/build.py
+++ b/code/web/routes/build.py
@@ -3,9 +3,11 @@ from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse
from typing import Any, Iterable
+import json
from ..app import (
ALLOW_MUST_HAVES,
ENABLE_CUSTOM_THEMES,
+ SHOW_MUST_HAVE_BUTTONS,
USER_THEME_LIMIT,
DEFAULT_THEME_MATCH_MODE,
_sanitize_theme,
@@ -13,6 +15,7 @@ from ..app import (
ENABLE_PARTNER_SUGGESTIONS,
)
from ..services.build_utils import (
+ step5_base_ctx,
step5_ctx_from_result,
step5_error_ctx,
step5_empty_ctx,
@@ -37,6 +40,7 @@ from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as
from ..services.telemetry import (
log_commander_create_deck,
log_partner_suggestion_selected,
+ log_include_exclude_toggle,
)
from ..services.partner_suggestions import get_partner_suggestions
from urllib.parse import urlparse, quote_plus
@@ -136,6 +140,90 @@ def warm_validation_name_cache() -> None:
pass
+def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None:
+ if not payload or response is None:
+ return
+ try:
+ existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None
+ except Exception:
+ existing = None
+ try:
+ if existing:
+ try:
+ data = json.loads(existing)
+ except Exception:
+ data = {}
+ if isinstance(data, dict):
+ data.update(payload)
+ response.headers["HX-Trigger"] = json.dumps(data)
+ return
+ response.headers["HX-Trigger"] = json.dumps(payload)
+ except Exception:
+ try:
+ response.headers["HX-Trigger"] = json.dumps(payload)
+ except Exception:
+ pass
+
+
+def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str:
+ text = message or "Deck summary will appear after the build completes."
+ return (
+ f'
'
+ )
+
+
+def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
+ includes = list(sess.get("include_cards") or [])
+ excludes = list(sess.get("exclude_cards") or [])
+ state = {
+ "includes": includes,
+ "excludes": excludes,
+ "enforcement_mode": (sess.get("enforcement_mode") or "warn"),
+ "allow_illegal": bool(sess.get("allow_illegal")),
+ "fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
+ }
+ return state, includes, excludes
+
+
+def _render_include_exclude_summary(
+ request: Request,
+ sess: dict,
+ sid: str,
+ *,
+ state: dict[str, Any] | None = None,
+ includes: list[str] | None = None,
+ excludes: list[str] | None = None,
+) -> HTMLResponse:
+ ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
+ if state is None or includes is None or excludes is None:
+ state, includes, excludes = _must_have_state(sess)
+ ctx["must_have_state"] = state
+ ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None
+ ctx["include_cards"] = includes
+ ctx["exclude_cards"] = excludes
+ response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
+ response.set_cookie("sid", sid, httponly=True, samesite="lax")
+ return response
+
+
+def _current_builder_summary(sess: dict) -> Any | None:
+ try:
+ ctx = sess.get("build_ctx") or {}
+ builder = ctx.get("builder") if isinstance(ctx, dict) else None
+ if builder is None:
+ return None
+ summary_fn = getattr(builder, "build_deck_summary", None)
+ if callable(summary_fn):
+ return summary_fn()
+ except Exception:
+ return None
+ return None
+
+
_COLOR_NAME_MAP = {
"W": "White",
"U": "Blue",
@@ -684,6 +772,117 @@ def _resolve_partner_selection(
router = APIRouter(prefix="/build")
+
+@router.post("/must-haves/toggle", response_class=HTMLResponse)
+async def toggle_must_haves(
+ request: Request,
+ card_name: str = Form(...),
+ list_type: str = Form(...),
+ enabled: str = Form("1"),
+):
+ if not ALLOW_MUST_HAVES:
+ return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403)
+
+ name = str(card_name or "").strip()
+ if not name:
+ return JSONResponse({"error": "Card name is required"}, status_code=400)
+
+ list_key = str(list_type or "").strip().lower()
+ if list_key not in {"include", "exclude"}:
+ return JSONResponse({"error": "Unsupported toggle type"}, status_code=400)
+
+ enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"}
+
+ sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
+ if not sid:
+ sid = new_sid()
+ sess = get_session(sid)
+
+ includes = list(sess.get("include_cards") or [])
+ excludes = list(sess.get("exclude_cards") or [])
+ include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()}
+ exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()}
+ key = name.lower()
+ display_name = include_lookup.get(key) or exclude_lookup.get(key) or name
+
+ changed = False
+ include_limit = 10
+ exclude_limit = 15
+
+ def _remove_casefold(items: list[str], item_key: str) -> list[str]:
+ return [c for c in items if str(c).strip().lower() != item_key]
+
+ if list_key == "include":
+ if enabled_flag:
+ if key not in include_lookup:
+ if len(include_lookup) >= include_limit:
+ return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400)
+ includes.append(name)
+ include_lookup[key] = name
+ changed = True
+ if key in exclude_lookup:
+ excludes = _remove_casefold(excludes, key)
+ exclude_lookup.pop(key, None)
+ changed = True
+ else:
+ if key in include_lookup:
+ includes = _remove_casefold(includes, key)
+ include_lookup.pop(key, None)
+ changed = True
+ else: # exclude
+ if enabled_flag:
+ if key not in exclude_lookup:
+ if len(exclude_lookup) >= exclude_limit:
+ return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400)
+ excludes.append(name)
+ exclude_lookup[key] = name
+ changed = True
+ if key in include_lookup:
+ includes = _remove_casefold(includes, key)
+ include_lookup.pop(key, None)
+ changed = True
+ else:
+ if key in exclude_lookup:
+ excludes = _remove_casefold(excludes, key)
+ exclude_lookup.pop(key, None)
+ changed = True
+
+ if changed:
+ sess["include_cards"] = includes
+ sess["exclude_cards"] = excludes
+ if "include_exclude_diagnostics" in sess:
+ try:
+ del sess["include_exclude_diagnostics"]
+ except Exception:
+ pass
+
+ response = _render_include_exclude_summary(request, sess, sid)
+
+ try:
+ log_include_exclude_toggle(
+ request,
+ card_name=display_name,
+ action=list_key,
+ enabled=enabled_flag,
+ include_count=len(includes),
+ exclude_count=len(excludes),
+ )
+ except Exception:
+ pass
+
+ trigger_payload = {
+ "card": display_name,
+ "list": list_key,
+ "enabled": enabled_flag,
+ "include_count": len(includes),
+ "exclude_count": len(excludes),
+ }
+ try:
+ _merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
+ except Exception:
+ pass
+ return response
+
# Alternatives cache moved to services/alts_utils
@@ -1132,6 +1331,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"form": {
"prefer_combos": bool(sess.get("prefer_combos")),
@@ -1531,6 +1731,7 @@ async def build_new_submit(
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"form": _form_state(suggested),
"tag_slot_html": None,
@@ -1554,6 +1755,7 @@ async def build_new_submit(
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"form": _form_state(commander),
"tag_slot_html": None,
@@ -1657,6 +1859,7 @@ async def build_new_submit(
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"form": _form_state(primary_commander_name),
"tag_slot_html": tag_slot_html,
@@ -1794,6 +1997,7 @@ async def build_new_submit(
"labels": orch.ideal_labels(),
"defaults": orch.ideal_defaults(),
"allow_must_haves": ALLOW_MUST_HAVES,
+ "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
"form": _form_state(sess.get("commander", "")),
"tag_slot_html": None,
@@ -2245,6 +2449,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}})
return resp
@@ -2884,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse:
base = step5_empty_ctx(request, sess)
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
@router.post("/step5/continue", response_class=HTMLResponse)
@@ -2954,6 +3160,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
stage_label = res.get("label")
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
@@ -2968,6 +3175,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp
@router.post("/step5/rerun", response_class=HTMLResponse)
@@ -3006,6 +3214,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
sess["last_step"] = 5
# Build locked cards list with ownership and in-deck presence
@@ -3032,6 +3241,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
ctx3["locked_cards"] = locked_cards
resp = templates.TemplateResponse("build/_step5.html", ctx3)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}})
return resp
@@ -3073,6 +3283,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}})
return resp
except Exception as e:
# Surface a friendly error on the step 5 screen with normalized context
@@ -3086,6 +3297,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
err_ctx["commander"] = commander
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
@router.get("/step5/start", response_class=HTMLResponse)
@@ -3151,8 +3363,61 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
})
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}})
return resp
+
+@router.get("/step5/summary", response_class=HTMLResponse)
+async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse:
+ sid = request.cookies.get("sid") or request.headers.get("X-Session-ID")
+ if not sid:
+ sid = new_sid()
+ sess = get_session(sid)
+
+ try:
+ session_token = int(sess.get("step5_summary_token", 0))
+ except Exception:
+ session_token = 0
+ try:
+ requested_token = int(token)
+ except Exception:
+ requested_token = 0
+ ready = bool(sess.get("step5_summary_ready"))
+ summary_data = sess.get("step5_summary") if ready else None
+ if summary_data is None and ready:
+ summary_data = _current_builder_summary(sess)
+ if summary_data is not None:
+ try:
+ sess["step5_summary"] = summary_data
+ except Exception:
+ pass
+
+ synergies: list[str] = []
+ try:
+ raw_synergies = sess.get("step5_synergies")
+ if isinstance(raw_synergies, (list, tuple, set)):
+ synergies = [str(item) for item in raw_synergies if str(item).strip()]
+ except Exception:
+ synergies = []
+
+ active_token = session_token if session_token >= requested_token else requested_token
+
+ if not ready or summary_data is None:
+ message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage."
+ placeholder = _step5_summary_placeholder_html(active_token, message=message)
+ response = HTMLResponse(placeholder)
+ response.set_cookie("sid", sid, httponly=True, samesite="lax")
+ return response
+
+ ctx = step5_base_ctx(request, sess)
+ ctx["summary"] = summary_data
+ ctx["synergies"] = synergies
+ ctx["summary_ready"] = True
+ ctx["summary_token"] = active_token
+ response = templates.TemplateResponse("partials/deck_summary.html", ctx)
+ response.set_cookie("sid", sid, httponly=True, samesite="lax")
+ return response
+
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
@router.post("/lock")
@@ -4244,6 +4509,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Ensure we have a CSV base stem for consistent re-exports
base_stem = None
@@ -4311,6 +4577,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}})
return resp
# Reload compliance JSON and summary
compliance = None
@@ -4358,6 +4625,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse:
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}})
return resp
@@ -4387,9 +4655,14 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
except Exception:
pass
- ctx2 = {"request": request, "compliance": comp}
+ try:
+ summary_token = int(sess.get("step5_summary_token", 0))
+ except Exception:
+ summary_token = 0
+ ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token}
resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
+ _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}})
return resp
diff --git a/code/web/routes/commanders.py b/code/web/routes/commanders.py
index 7545f0e..88053b5 100644
--- a/code/web/routes/commanders.py
+++ b/code/web/routes/commanders.py
@@ -1,9 +1,10 @@
from __future__ import annotations
+from collections import OrderedDict
from dataclasses import dataclass
from difflib import SequenceMatcher
from math import ceil
-from typing import Iterable, Mapping, Sequence
+from typing import Dict, Iterable, Mapping, Sequence, Tuple
from urllib.parse import urlencode
import re
@@ -11,7 +12,7 @@ from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from ..app import templates
-from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog
+from ..services.commander_catalog_loader import CommanderCatalog, CommanderRecord, load_commander_catalog
from ..services.theme_catalog_loader import load_index, slugify
from ..services.telemetry import log_commander_page_view
@@ -89,6 +90,20 @@ class ThemeRecommendation:
score: float
+@dataclass(slots=True)
+class CommanderFilterCacheEntry:
+ records: Tuple[CommanderRecord, ...]
+ theme_recommendations: Tuple[ThemeRecommendation, ...]
+ page_views: Dict[int, Tuple[CommanderView, ...]]
+
+
+_FILTER_CACHE_MAX = 48
+_FILTER_CACHE: "OrderedDict[tuple[str, str, str, str], CommanderFilterCacheEntry]" = OrderedDict()
+_THEME_OPTIONS_CACHE: Dict[str, Tuple[str, ...]] = {}
+_COLOR_OPTIONS_CACHE: Dict[str, Tuple[Tuple[str, str], ...]] = {}
+_LAST_SEEN_ETAG: str | None = None
+
+
def _is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request", "").lower() == "true"
@@ -142,6 +157,74 @@ def _color_label_from_code(code: str) -> str:
return f"{pretty} ({code})"
+def _cache_key_for_filters(etag: str, query: str | None, theme_query: str | None, color: str | None) -> tuple[str, str, str, str]:
+ def _normalize(text: str | None) -> str:
+ return (text or "").strip().lower()
+
+ return (
+ etag,
+ _normalize(query),
+ _normalize(theme_query),
+ (color or "").strip().upper(),
+ )
+
+
+def _ensure_catalog_caches(etag: str) -> None:
+ global _LAST_SEEN_ETAG
+ if _LAST_SEEN_ETAG == etag:
+ return
+ _LAST_SEEN_ETAG = etag
+ _FILTER_CACHE.clear()
+ _THEME_OPTIONS_CACHE.clear()
+ _COLOR_OPTIONS_CACHE.clear()
+
+
+def _theme_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[str, ...]:
+ cached = _THEME_OPTIONS_CACHE.get(etag)
+ if cached is not None:
+ return cached
+ options = _collect_theme_names(entries)
+ result = tuple(options)
+ _THEME_OPTIONS_CACHE[etag] = result
+ return result
+
+
+def _color_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[Tuple[str, str], ...]:
+ cached = _COLOR_OPTIONS_CACHE.get(etag)
+ if cached is not None:
+ return cached
+ options = tuple(_build_color_options(entries))
+ _COLOR_OPTIONS_CACHE[etag] = options
+ return options
+
+
+def _get_cached_filter_entry(
+ catalog: CommanderCatalog,
+ query: str | None,
+ theme_query: str | None,
+ canon_color: str | None,
+ theme_options: Sequence[str],
+) -> CommanderFilterCacheEntry:
+ key = _cache_key_for_filters(catalog.etag, query, theme_query, canon_color)
+ cached = _FILTER_CACHE.get(key)
+ if cached is not None:
+ _FILTER_CACHE.move_to_end(key)
+ return cached
+
+ filtered = tuple(_filter_commanders(catalog.entries, query, canon_color, theme_query))
+ recommendations = tuple(_build_theme_recommendations(theme_query, theme_options))
+ entry = CommanderFilterCacheEntry(
+ records=filtered,
+ theme_recommendations=recommendations,
+ page_views={},
+ )
+ _FILTER_CACHE[key] = entry
+ _FILTER_CACHE.move_to_end(key)
+ if len(_FILTER_CACHE) > _FILTER_CACHE_MAX:
+ _FILTER_CACHE.popitem(last=False)
+ return entry
+
+
def _color_aria_label(record: CommanderRecord) -> str:
if record.color_identity:
names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity]
@@ -351,13 +434,19 @@ def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[
return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT])
-def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]:
- items = list(records)
+def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> Sequence[CommanderRecord]:
+ items: Sequence[CommanderRecord]
+ if isinstance(records, Sequence):
+ items = records
+ else:
+ items = tuple(records)
+
color_code = _canon_color_code(color)
if color_code:
items = [rec for rec in items if _record_color_code(rec) == color_code]
+
normalized_query = _normalize_search_text(q)
- if normalized_query:
+ if normalized_query and items:
filtered: list[tuple[float, CommanderRecord]] = []
for rec in items:
score = _commander_name_match_score(normalized_query, rec)
@@ -368,6 +457,7 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
items = [rec for _, rec in filtered]
else:
items = []
+
normalized_theme_query = _normalize_search_text(theme)
if normalized_theme_query and items:
theme_tokens = tuple(normalized_theme_query.split())
@@ -381,7 +471,10 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color:
items = [rec for _, rec in filtered_by_theme]
else:
items = []
- return items
+
+ if isinstance(items, list):
+ return items
+ return tuple(items)
def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]:
@@ -441,27 +534,69 @@ async def commanders_index(
color: str | None = Query(default=None, alias="color"),
page: int = Query(default=1, ge=1),
) -> HTMLResponse:
+ catalog: CommanderCatalog | None = None
entries: Sequence[CommanderRecord] = ()
error: str | None = None
try:
catalog = load_commander_catalog()
entries = catalog.entries
+ _ensure_catalog_caches(catalog.etag)
except FileNotFoundError:
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
- theme_names = _collect_theme_names(entries)
+ except Exception:
+ error = "Commander catalog failed to load. Check server logs."
+
theme_query = (theme or "").strip()
- filtered = _filter_commanders(entries, q, color, theme_query)
- theme_recommendations = _build_theme_recommendations(theme_query, theme_names)
- total_filtered = len(filtered)
- page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
- if page > page_count:
- page = page_count
- start_index = (page - 1) * PAGE_SIZE
- end_index = start_index + PAGE_SIZE
- page_records = filtered[start_index:end_index]
- theme_info = _build_theme_info(page_records)
- views = [_record_to_view(rec, theme_info) for rec in page_records]
- color_options = _build_color_options(entries) if entries else []
+ query_value = (q or "").strip()
+ canon_color = _canon_color_code(color)
+
+ theme_names: Tuple[str, ...] = ()
+ color_options: Tuple[Tuple[str, str], ...] | list[Tuple[str, str]] = ()
+ filter_entry: CommanderFilterCacheEntry | None = None
+ total_filtered = 0
+ page_count = 1
+ page_records: Sequence[CommanderRecord] = ()
+ views: Tuple[CommanderView, ...] = ()
+ theme_recommendations: Tuple[ThemeRecommendation, ...] = ()
+
+ if catalog is not None:
+ theme_names = _theme_options_for_catalog(entries, etag=catalog.etag)
+ color_options = _color_options_for_catalog(entries, etag=catalog.etag)
+ filter_entry = _get_cached_filter_entry(
+ catalog,
+ query_value,
+ theme_query,
+ canon_color,
+ theme_names,
+ )
+ total_filtered = len(filter_entry.records)
+ page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
+ if page > page_count:
+ page = page_count
+ if page < 1:
+ page = 1
+ start_index = (page - 1) * PAGE_SIZE
+ end_index = start_index + PAGE_SIZE
+ page_records = filter_entry.records[start_index:end_index]
+ cached_views = filter_entry.page_views.get(page) if filter_entry else None
+ if cached_views is None:
+ theme_info = _build_theme_info(page_records)
+ computed_views = tuple(_record_to_view(rec, theme_info) for rec in page_records)
+ if filter_entry is not None:
+ filter_entry.page_views[page] = computed_views
+ if len(filter_entry.page_views) > 6:
+ oldest_key = next(iter(filter_entry.page_views))
+ if oldest_key != page:
+ filter_entry.page_views.pop(oldest_key, None)
+ views = computed_views
+ else:
+ views = cached_views
+ theme_recommendations = filter_entry.theme_recommendations
+ else:
+ page = 1
+ start_index = 0
+ end_index = 0
+
page_start = start_index + 1 if total_filtered else 0
page_end = start_index + len(page_records)
has_prev = page > 1
@@ -494,12 +629,12 @@ async def commanders_index(
context = {
"request": request,
"commanders": views,
- "query": q or "",
- "theme_query": theme_query,
+ "query": query_value,
+ "theme_query": theme_query,
"color": canon_color,
- "color_options": color_options,
- "theme_options": theme_names,
- "theme_recommendations": theme_recommendations,
+ "color_options": list(color_options) if color_options else [],
+ "theme_options": theme_names,
+ "theme_recommendations": theme_recommendations,
"total_count": len(entries),
"result_count": len(views),
"result_total": total_filtered,
@@ -540,3 +675,23 @@ async def commanders_index_alias(
page: int = Query(default=1, ge=1),
) -> HTMLResponse:
return await commanders_index(request, q=q, theme=theme, color=color, page=page)
+
+
+def prewarm_default_page() -> None:
+ """Prime the commander catalog caches for the default (no-filter) view."""
+
+ try:
+ catalog = load_commander_catalog()
+ except Exception:
+ return
+
+ try:
+ _ensure_catalog_caches(catalog.etag)
+ theme_options = _theme_options_for_catalog(catalog.entries, etag=catalog.etag)
+ entry = _get_cached_filter_entry(catalog, "", "", "", theme_options)
+ if 1 not in entry.page_views:
+ page_records = entry.records[:PAGE_SIZE]
+ theme_info = _build_theme_info(page_records)
+ entry.page_views[1] = tuple(_record_to_view(rec, theme_info) for rec in page_records)
+ except Exception:
+ return
diff --git a/code/web/routes/telemetry.py b/code/web/routes/telemetry.py
new file mode 100644
index 0000000..90842c7
--- /dev/null
+++ b/code/web/routes/telemetry.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Request
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+from typing import Any, Dict
+
+from ..services.telemetry import log_frontend_event
+
+router = APIRouter(prefix="/telemetry", tags=["telemetry"])
+
+
+class TelemetryEvent(BaseModel):
+ event: str = Field(..., min_length=1)
+ data: Dict[str, Any] | None = None
+
+
+@router.post("/events", status_code=204)
+async def ingest_event(payload: TelemetryEvent, request: Request) -> Response:
+ log_frontend_event(request, event=payload.event, data=payload.data or {})
+ return Response(status_code=204)
diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py
index 9634999..ee97e43 100644
--- a/code/web/services/build_utils.py
+++ b/code/web/services/build_utils.py
@@ -5,7 +5,48 @@ from fastapi import Request
from ..services import owned_store
from . import orchestrator as orch
from deck_builder import builder_constants as bc
-from .. import app as app_module
+
+
+_TRUE_SET = {"1", "true", "yes", "on", "y", "t"}
+_FALSE_SET = {"0", "false", "no", "off", "n", "f"}
+
+
+def _coerce_bool(value: object, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return default
+ if isinstance(value, str):
+ token = value.strip().lower()
+ if not token:
+ return default
+ if token in _TRUE_SET:
+ return True
+ if token in _FALSE_SET:
+ return False
+ try:
+ return bool(value)
+ except Exception:
+ return default
+
+
+def _app_bool(name: str, default: bool = False) -> bool:
+ import os
+ import sys
+
+ env_val = os.getenv(name)
+ if env_val is not None:
+ return _coerce_bool(env_val, default)
+
+ app_module = sys.modules.get("code.web.app")
+ if app_module is not None:
+ try:
+ if hasattr(app_module, name):
+ return _coerce_bool(getattr(app_module, name), default)
+ except Exception:
+ return default
+
+ return default
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
@@ -14,6 +55,8 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode,
combo preferences, and static game_changers. Caller can layer run-specific results.
"""
+ include_cards = list(sess.get("include_cards", []) or [])
+ exclude_cards = list(sess.get("exclude_cards", []) or [])
ctx: Dict[str, Any] = {
"request": request,
"commander": sess.get("commander"),
@@ -22,25 +65,49 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
- "partner_enabled": bool(sess.get("partner_enabled") and app_module.ENABLE_PARTNER_MECHANICS),
+ "partner_enabled": bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True),
"secondary_commander": sess.get("secondary_commander"),
"background": sess.get("background"),
"partner_mode": sess.get("partner_mode"),
"partner_warnings": list(sess.get("partner_warnings", []) or []),
"combined_commander": sess.get("combined_commander"),
"partner_auto_note": sess.get("partner_auto_note"),
- "owned_set": owned_set(),
+ "owned_set": owned_set(),
"game_changers": bc.GAME_CHANGERS,
"replace_mode": bool(sess.get("replace_mode", True)),
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_target_count": int(sess.get("combo_target_count", 2)),
"combo_balance": str(sess.get("combo_balance", "mix")),
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
+ "allow_must_haves": _app_bool("ALLOW_MUST_HAVES", True),
+ "show_must_have_buttons": _app_bool("SHOW_MUST_HAVE_BUTTONS", False),
+ "include_cards": include_cards,
+ "exclude_cards": exclude_cards,
}
if include_name:
ctx["name"] = sess.get("custom_export_base")
if include_locks:
ctx["locks"] = list(sess.get("locks", []))
+ try:
+ ctx["summary_token"] = int(sess.get("step5_summary_token", 0))
+ except Exception:
+ ctx["summary_token"] = 0
+ ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
+ try:
+ raw_synergies = sess.get("step5_synergies")
+ if isinstance(raw_synergies, (list, tuple, set)):
+ ctx["synergies"] = [str(s) for s in raw_synergies if str(s).strip()]
+ else:
+ ctx["synergies"] = []
+ except Exception:
+ ctx["synergies"] = []
+ ctx["must_have_state"] = {
+ "includes": include_cards,
+ "excludes": exclude_cards,
+ "enforcement_mode": (sess.get("enforcement_mode") or "warn"),
+ "allow_illegal": bool(sess.get("allow_illegal")),
+ "fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
+ }
return ctx
@@ -77,7 +144,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names_list = owned_names() if (use_owned or prefer) else None
- partner_enabled = bool(sess.get("partner_enabled")) and app_module.ENABLE_PARTNER_MECHANICS
+ partner_enabled = bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True)
secondary_commander = sess.get("secondary_commander") if partner_enabled else None
background_choice = sess.get("background") if partner_enabled else None
ctx = orch.start_build_ctx(
@@ -311,18 +378,48 @@ def step5_ctx_from_result(
"""
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
done = bool(res.get("done"))
+ include_lower = {str(name).strip().lower(): str(name) for name in (sess.get("include_cards") or []) if str(name).strip()}
+ exclude_lower = {str(name).strip().lower(): str(name) for name in (sess.get("exclude_cards") or []) if str(name).strip()}
+
+ raw_added = list(res.get("added_cards", []) or [])
+ normalized_added: list[dict[str, Any]] = []
+ for item in raw_added:
+ if isinstance(item, dict):
+ entry: dict[str, Any] = dict(item)
+ else:
+ entry = {}
+ try:
+ entry.update(vars(item)) # type: ignore[arg-type]
+ except Exception:
+ pass
+ # Preserve common attributes when vars() empty
+ for attr in ("name", "role", "sub_role", "tags", "tags_slug", "reason", "count"):
+ if attr not in entry and hasattr(item, attr):
+ try:
+ entry[attr] = getattr(item, attr)
+ except Exception:
+ continue
+ name_val = str(entry.get("name") or "").strip()
+ key = name_val.lower()
+ entry["name"] = name_val
+ entry["must_include"] = key in include_lower
+ entry["must_exclude"] = key in exclude_lower
+ entry["must_include_label"] = include_lower.get(key)
+ entry["must_exclude_label"] = exclude_lower.get(key)
+ normalized_added.append(entry)
+
ctx: Dict[str, Any] = {
**base,
"status": status_text,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
- "added_cards": res.get("added_cards", []),
+ "added_cards": normalized_added,
"i": res.get("idx"),
"n": res.get("total"),
"csv_path": res.get("csv_path") if done else None,
"txt_path": res.get("txt_path") if done else None,
"summary": res.get("summary") if done else None,
- "compliance": res.get("compliance") if done else None,
+ "compliance": res.get("compliance") if done else None,
"show_skipped": bool(show_skipped),
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
@@ -330,7 +427,7 @@ def step5_ctx_from_result(
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
- "gated": bool(res.get("gated")),
+ "gated": bool(res.get("gated")),
}
if extras:
ctx.update(extras)
@@ -344,6 +441,57 @@ def step5_ctx_from_result(
ctx.update(hover_meta)
if "commander_display_name" not in ctx or not ctx.get("commander_display_name"):
ctx["commander_display_name"] = ctx.get("commander")
+
+ try:
+ token_val = int(sess.get("step5_summary_token", 0))
+ except Exception:
+ token_val = 0
+ summary_value = ctx.get("summary")
+ synergies_list: list[str] = []
+ if summary_value is not None:
+ try:
+ sess["step5_summary"] = summary_value
+ except Exception:
+ pass
+ if isinstance(summary_value, dict):
+ raw_synergies = summary_value.get("synergies")
+ if isinstance(raw_synergies, (list, tuple, set)):
+ synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
+ else:
+ meta = summary_value.get("meta") if isinstance(summary_value.get("meta"), dict) else {}
+ if isinstance(meta, dict):
+ raw_synergies = meta.get("synergies") or meta.get("commander_synergies")
+ if isinstance(raw_synergies, (list, tuple, set)):
+ synergies_list = [str(item) for item in raw_synergies if str(item).strip()]
+ token_val += 1
+ sess["step5_summary_token"] = token_val
+ sess["step5_summary_ready"] = True
+ if synergies_list:
+ sess["step5_synergies"] = synergies_list
+ else:
+ try:
+ if "step5_synergies" in sess:
+ del sess["step5_synergies"]
+ except Exception:
+ pass
+ else:
+ token_val += 1
+ sess["step5_summary_token"] = token_val
+ sess["step5_summary_ready"] = False
+ try:
+ if "step5_summary" in sess:
+ del sess["step5_summary"]
+ except Exception:
+ pass
+ try:
+ if "step5_synergies" in sess:
+ del sess["step5_synergies"]
+ except Exception:
+ pass
+ synergies_list = []
+ ctx["summary_token"] = token_val
+ ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
+ ctx["synergies"] = synergies_list
return ctx
@@ -379,6 +527,25 @@ def step5_error_ctx(
"added_total": 0,
"skipped": False,
}
+ try:
+ token_val = int(sess.get("step5_summary_token", 0)) + 1
+ except Exception:
+ token_val = 1
+ sess["step5_summary_token"] = token_val
+ sess["step5_summary_ready"] = False
+ try:
+ if "step5_summary" in sess:
+ del sess["step5_summary"]
+ except Exception:
+ pass
+ try:
+ if "step5_synergies" in sess:
+ del sess["step5_synergies"]
+ except Exception:
+ pass
+ ctx["summary_token"] = token_val
+ ctx["summary_ready"] = False
+ ctx["synergies"] = []
if extras:
ctx.update(extras)
return ctx
@@ -410,6 +577,25 @@ def step5_empty_ctx(
"show_skipped": False,
"skipped": False,
}
+ try:
+ token_val = int(sess.get("step5_summary_token", 0)) + 1
+ except Exception:
+ token_val = 1
+ sess["step5_summary_token"] = token_val
+ sess["step5_summary_ready"] = False
+ try:
+ if "step5_summary" in sess:
+ del sess["step5_summary"]
+ except Exception:
+ pass
+ try:
+ if "step5_synergies" in sess:
+ del sess["step5_synergies"]
+ except Exception:
+ pass
+ ctx["summary_token"] = token_val
+ ctx["summary_ready"] = False
+ ctx["synergies"] = []
if extras:
ctx.update(extras)
return ctx
diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py
index 76b5361..aee1a3f 100644
--- a/code/web/services/summary_utils.py
+++ b/code/web/services/summary_utils.py
@@ -2,10 +2,23 @@ from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional
from deck_builder import builder_constants as bc
-from .build_utils import owned_set as owned_set_helper
from .combo_utils import detect_for_summary as _detect_for_summary
+def _owned_set_helper() -> set[str]:
+ try:
+ from .build_utils import owned_set as _owned_set # type: ignore
+
+ return _owned_set()
+ except Exception:
+ try:
+ from . import owned_store
+
+ return {str(n).strip().lower() for n in owned_store.get_names()}
+ except Exception:
+ return set()
+
+
def _sanitize_tag_list(values: Iterable[Any]) -> List[str]:
cleaned: List[str] = []
for raw in values or []: # type: ignore[arg-type]
@@ -148,7 +161,7 @@ def summary_ctx(
synergy_tags.append(label)
versions = det.get("versions", {} if include_versions else None)
return {
- "owned_set": owned_set_helper(),
+ "owned_set": _owned_set_helper(),
"game_changers": bc.GAME_CHANGERS,
"combos": combos,
"synergies": synergy_tags,
diff --git a/code/web/services/telemetry.py b/code/web/services/telemetry.py
index 151b325..dd0291f 100644
--- a/code/web/services/telemetry.py
+++ b/code/web/services/telemetry.py
@@ -11,10 +11,14 @@ __all__ = [
"log_commander_create_deck",
"log_partner_suggestions_generated",
"log_partner_suggestion_selected",
+ "log_include_exclude_toggle",
+ "log_frontend_event",
]
_LOGGER = logging.getLogger("web.commander_browser")
_PARTNER_LOGGER = logging.getLogger("web.partner_suggestions")
+_MUST_HAVE_LOGGER = logging.getLogger("web.must_haves")
+_FRONTEND_LOGGER = logging.getLogger("web.frontend_events")
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
@@ -217,3 +221,45 @@ def log_partner_suggestion_selected(
if warnings:
payload["warnings"] = list(warnings)
_emit(_PARTNER_LOGGER, payload)
+
+
+def log_include_exclude_toggle(
+ request: Request,
+ *,
+ card_name: str,
+ action: str,
+ enabled: bool,
+ include_count: int,
+ exclude_count: int,
+) -> None:
+ payload: Dict[str, Any] = {
+ "event": "must_haves.toggle",
+ "request_id": _request_id(request),
+ "path": str(request.url.path),
+ "card": card_name,
+ "list": action,
+ "enabled": bool(enabled),
+ "include_count": int(include_count),
+ "exclude_count": int(exclude_count),
+ "client_ip": _client_ip(request),
+ }
+ _emit(_MUST_HAVE_LOGGER, payload)
+
+
+def log_frontend_event(
+ request: Request,
+ event: str,
+ data: Mapping[str, Any] | None,
+) -> None:
+ snapshot: Dict[str, Any] = {}
+ if isinstance(data, Mapping):
+ snapshot = {str(k): data[k] for k in data}
+ payload: Dict[str, Any] = {
+ "event": f"frontend.{event}",
+ "request_id": _request_id(request),
+ "path": str(request.url.path),
+ "data": snapshot,
+ "referer": request.headers.get("referer"),
+ "client_ip": _client_ip(request),
+ }
+ _emit(_FRONTEND_LOGGER, payload)
diff --git a/code/web/static/app.js b/code/web/static/app.js
index eb129dc..fa1e2ad 100644
--- a/code/web/static/app.js
+++ b/code/web/static/app.js
@@ -57,6 +57,36 @@
}
window.toastHTML = toastHTML;
+ var telemetryEndpoint = (function(){
+ if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){
+ return window.__telemetryEndpoint.trim();
+ }
+ return '/telemetry/events';
+ })();
+ var telemetry = {
+ send: function(eventName, data){
+ if (!telemetryEndpoint || !eventName) return;
+ var payload;
+ try {
+ payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() });
+ } catch(_){ return; }
+ try {
+ if (navigator.sendBeacon){
+ var blob = new Blob([payload], { type: 'application/json' });
+ navigator.sendBeacon(telemetryEndpoint, blob);
+ } else if (window.fetch){
+ fetch(telemetryEndpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: payload,
+ keepalive: true,
+ }).catch(function(){ /* noop */ });
+ }
+ } catch(_){ }
+ }
+ };
+ window.appTelemetry = telemetry;
+
// Global HTMX error handling => toast
document.addEventListener('htmx:responseError', function(e){
var detail = e.detail || {}; var xhr = detail.xhr || {};
@@ -136,19 +166,379 @@
}
addFocusVisible();
- // Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
- function showSkeletons(container){
- (container || document).querySelectorAll('[data-skeleton]')
- .forEach(function(el){ el.classList.add('is-loading'); });
+ // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable
+ var SKELETON_DELAY_DEFAULT = 400;
+ var skeletonTimers = new WeakMap();
+ function gatherSkeletons(root){
+ if (!root){ return []; }
+ var list = [];
+ var scope = (root.nodeType === 9) ? root.documentElement : root;
+ if (scope && scope.matches && scope.hasAttribute('data-skeleton')){
+ list.push(scope);
+ }
+ if (scope && scope.querySelectorAll){
+ scope.querySelectorAll('[data-skeleton]').forEach(function(el){
+ if (list.indexOf(el) === -1){ list.push(el); }
+ });
+ }
+ return list;
}
- function hideSkeletons(container){
- (container || document).querySelectorAll('[data-skeleton]')
- .forEach(function(el){ el.classList.remove('is-loading'); });
+ function scheduleSkeleton(el){
+ var delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10);
+ var delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr);
+ clearSkeleton(el, false);
+ var timer = setTimeout(function(){
+ el.classList.add('is-loading');
+ el.setAttribute('aria-busy', 'true');
+ skeletonTimers.set(el, null);
+ }, delay);
+ skeletonTimers.set(el, timer);
+ }
+ function clearSkeleton(el, removeBusy){
+ var timer = skeletonTimers.get(el);
+ if (typeof timer === 'number'){
+ clearTimeout(timer);
+ }
+ skeletonTimers.delete(el);
+ el.classList.remove('is-loading');
+ if (removeBusy !== false){ el.removeAttribute('aria-busy'); }
+ }
+ function showSkeletons(context){
+ gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); });
+ }
+ function hideSkeletons(context){
+ gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
- document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
- document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
+ document.addEventListener('htmx:beforeRequest', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var target = detail.target || detail.elt || e.target;
+ showSkeletons(target);
+ });
+ document.addEventListener('htmx:afterSwap', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var target = detail.target || detail.elt || e.target;
+ hideSkeletons(target);
+ });
+ document.addEventListener('htmx:afterRequest', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var target = detail.target || detail.elt || e.target;
+ hideSkeletons(target);
+ });
+
+ // Commander catalog image lazy loader
+ (function(){
+ var PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
+ var observer = null;
+ var supportsIO = 'IntersectionObserver' in window;
+
+ function ensureObserver(){
+ if (observer || !supportsIO) return observer;
+ observer = new IntersectionObserver(function(entries){
+ entries.forEach(function(entry){
+ if (entry.isIntersecting || entry.intersectionRatio > 0){
+ var img = entry.target;
+ load(img);
+ if (observer) observer.unobserve(img);
+ }
+ });
+ }, { rootMargin: '160px 0px', threshold: 0.05 });
+ return observer;
+ }
+
+ function load(img){
+ if (!img || img.__lazyLoaded) return;
+ var src = img.getAttribute('data-lazy-src');
+ if (src){ img.setAttribute('src', src); }
+ var srcset = img.getAttribute('data-lazy-srcset');
+ if (srcset){ img.setAttribute('srcset', srcset); }
+ var sizes = img.getAttribute('data-lazy-sizes');
+ if (sizes){ img.setAttribute('sizes', sizes); }
+ img.classList.remove('is-placeholder');
+ img.removeAttribute('data-lazy-image');
+ img.removeAttribute('data-lazy-src');
+ img.removeAttribute('data-lazy-srcset');
+ img.removeAttribute('data-lazy-sizes');
+ img.__lazyLoaded = true;
+ }
+
+ function prime(img){
+ if (!img || img.__lazyPrimed) return;
+ var desired = img.getAttribute('data-lazy-src');
+ if (!desired) return;
+ img.__lazyPrimed = true;
+ var placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL;
+ img.setAttribute('loading', 'lazy');
+ img.setAttribute('decoding', 'async');
+ img.classList.add('is-placeholder');
+ img.removeAttribute('srcset');
+ img.removeAttribute('sizes');
+ img.setAttribute('src', placeholder);
+ if (supportsIO){
+ ensureObserver().observe(img);
+ } else {
+ var loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); };
+ loader(function(){ load(img); });
+ }
+ }
+
+ function collect(scope){
+ if (!scope) scope = document;
+ if (scope === document){
+ return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]'));
+ }
+ if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){
+ return [scope];
+ }
+ if (scope.querySelectorAll){
+ return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]'));
+ }
+ return [];
+ }
+
+ function process(scope){
+ collect(scope).forEach(function(img){
+ if (img.__lazyLoaded) return;
+ prime(img);
+ });
+ }
+
+ if (document.readyState === 'loading'){
+ document.addEventListener('DOMContentLoaded', function(){ process(document); });
+ } else {
+ process(document);
+ }
+
+ document.addEventListener('htmx:afterSwap', function(evt){
+ var target = evt && evt.detail ? evt.detail.target : null;
+ process(target || document);
+ });
+ })();
+
+ var htmxCache = (function(){
+ var store = new Map();
+ function ttlFor(elt){
+ var raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10);
+ if (isNaN(raw) || raw <= 0){ return 30000; }
+ return Math.max(1000, raw);
+ }
+ function buildKey(detail, elt){
+ if (!detail) detail = {};
+ if (elt && elt.getAttribute){
+ var explicit = elt.getAttribute('data-hx-cache-key');
+ if (explicit && explicit.trim()){ return explicit.trim(); }
+ }
+ var verb = (detail.verb || 'GET').toUpperCase();
+ var path = detail.path || '';
+ var params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : '';
+ return verb + ' ' + path + ' ' + params;
+ }
+ function set(key, html, ttl, meta){
+ if (!key || typeof html !== 'string') return;
+ store.set(key, {
+ key: key,
+ html: html,
+ expires: Date.now() + (ttl || 30000),
+ meta: meta || {},
+ });
+ }
+ function get(key){
+ if (!key) return null;
+ var entry = store.get(key);
+ if (!entry) return null;
+ if (entry.expires && entry.expires <= Date.now()){
+ store.delete(key);
+ return null;
+ }
+ return entry;
+ }
+ function applyCached(elt, detail, entry){
+ if (!entry) return;
+ var target = detail && detail.target ? detail.target : elt;
+ if (!target) return;
+ dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ var swapSpec = '';
+ try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ }
+ swapSpec = (swapSpec || 'innerHTML').toLowerCase();
+ if (swapSpec.indexOf('outer') === 0){
+ if (target.outerHTML !== undefined){
+ target.outerHTML = entry.html;
+ }
+ } else if (target.innerHTML !== undefined){
+ target.innerHTML = entry.html;
+ }
+ if (window.htmx && typeof window.htmx.process === 'function'){
+ window.htmx.process(target);
+ }
+ dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key });
+ }
+ function prefetch(url, opts){
+ if (!url) return;
+ opts = opts || {};
+ var key = opts.key || ('GET ' + url);
+ if (get(key)) return;
+ try {
+ fetch(url, {
+ headers: { 'HX-Request': 'true', 'Accept': 'text/html' },
+ cache: 'no-store',
+ }).then(function(resp){
+ if (!resp.ok) throw new Error('prefetch failed');
+ return resp.text();
+ }).then(function(html){
+ set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true });
+ telemetry.send('htmx.cache.prefetch', { key: key, url: url });
+ }).catch(function(){ /* noop */ });
+ } catch(_){ }
+ }
+ return {
+ set: set,
+ get: get,
+ apply: applyCached,
+ buildKey: buildKey,
+ ttlFor: ttlFor,
+ prefetch: prefetch,
+ };
+ })();
+ window.htmxCache = htmxCache;
+
+ document.addEventListener('htmx:configRequest', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var elt = detail.elt;
+ if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return;
+ var verb = (detail.verb || 'GET').toUpperCase();
+ if (verb !== 'GET') return;
+ var key = htmxCache.buildKey(detail, elt);
+ elt.__hxCacheKey = key;
+ elt.__hxCacheTTL = htmxCache.ttlFor(elt);
+ detail.headers = detail.headers || {};
+ try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ }
+ });
+
+ document.addEventListener('htmx:beforeRequest', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var elt = detail.elt;
+ if (!elt || !elt.__hxCacheKey) return;
+ var entry = htmxCache.get(elt.__hxCacheKey);
+ if (entry){
+ telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' });
+ e.preventDefault();
+ htmxCache.apply(elt, detail, entry);
+ } else {
+ telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' });
+ }
+ });
+
+ document.addEventListener('htmx:afterSwap', function(e){
+ var detail = e && e.detail ? e.detail : {};
+ var elt = detail.elt;
+ if (!elt || !elt.__hxCacheKey) return;
+ try {
+ var xhr = detail.xhr;
+ var status = xhr && xhr.status ? xhr.status : 0;
+ if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){
+ var ttl = elt.__hxCacheTTL || 30000;
+ htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' });
+ telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl });
+ }
+ } catch(_){ }
+ elt.__hxCacheKey = null;
+ elt.__hxCacheTTL = null;
+ });
+
+ (function(){
+ function handlePrefetch(evt){
+ try {
+ var el = evt.target && evt.target.closest ? evt.target.closest('[data-hx-prefetch]') : null;
+ if (!el || el.__hxPrefetched) return;
+ var url = el.getAttribute('data-hx-prefetch');
+ if (!url) return;
+ el.__hxPrefetched = true;
+ var key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url);
+ var ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10);
+ var ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr);
+ htmxCache.prefetch(url, { key: key, ttl: ttl });
+ } catch(_){ }
+ }
+ document.addEventListener('pointerenter', handlePrefetch, true);
+ document.addEventListener('focusin', handlePrefetch, true);
+ })();
+
+ // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce)
+ var hxDebounceGroups = new Map();
+ function dispatchHtmx(el, evtName, detail){
+ if (!el) return;
+ if (window.htmx && typeof window.htmx.trigger === 'function'){
+ window.htmx.trigger(el, evtName, detail);
+ } else {
+ try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ }
+ }
+ }
+ function bindHtmxDebounce(el){
+ if (!el || el.__hxDebounceBound) return;
+ el.__hxDebounceBound = true;
+ var delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10);
+ var delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw);
+ var eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input';
+ var events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean);
+ if (!events.length){ events = ['input']; }
+ var trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput';
+ var group = el.getAttribute('data-hx-debounce-group') || '';
+ var flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase();
+ var flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true');
+ function clearTimer(){
+ if (el.__hxDebounceTimer){
+ clearTimeout(el.__hxDebounceTimer);
+ el.__hxDebounceTimer = null;
+ }
+ }
+ function schedule(){
+ clearTimer();
+ if (group){
+ var prev = hxDebounceGroups.get(group);
+ if (prev && prev !== el && prev.__hxDebounceTimer){
+ clearTimeout(prev.__hxDebounceTimer);
+ prev.__hxDebounceTimer = null;
+ }
+ hxDebounceGroups.set(group, el);
+ }
+ el.__hxDebounceTimer = setTimeout(function(){
+ el.__hxDebounceTimer = null;
+ dispatchHtmx(el, trigger);
+ }, delay);
+ }
+ events.forEach(function(evt){
+ el.addEventListener(evt, schedule, { passive: true });
+ });
+ if (flushOnBlur){
+ el.addEventListener('blur', function(){
+ if (el.__hxDebounceTimer){
+ clearTimer();
+ dispatchHtmx(el, trigger);
+ }
+ });
+ }
+ el.addEventListener('htmx:beforeRequest', clearTimer);
+ }
+ function initHtmxDebounce(root){
+ var scope = root || document;
+ if (scope === document){ scope = document.body || document; }
+ if (!scope) return;
+ var seen = new Set();
+ function collect(candidate){
+ if (!candidate || seen.has(candidate)) return;
+ seen.add(candidate);
+ bindHtmxDebounce(candidate);
+ }
+ if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){
+ collect(scope);
+ }
+ if (scope.querySelectorAll){
+ scope.querySelectorAll('[data-hx-debounce]').forEach(collect);
+ }
+ }
+ window.initHtmxDebounce = initHtmxDebounce;
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
@@ -172,7 +562,9 @@
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
- initVirtualization(document);
+ initVirtualization(document);
+ initHtmxDebounce(document);
+ initMustHaveControls(document);
});
// Hydrate progress bars with width based on data-pct
@@ -200,7 +592,9 @@
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
- initVirtualization(e.target);
+ initVirtualization(e.target);
+ initHtmxDebounce(e.target);
+ initMustHaveControls(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
@@ -404,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;
@@ -427,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;
@@ -443,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 });
@@ -458,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);
@@ -507,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;
@@ -534,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';
@@ -543,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){
@@ -651,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(_){ }
@@ -663,6 +1188,137 @@
}catch(_){ }
}
+ function setTileState(tile, type, active){
+ if (!tile) return;
+ var attr = 'data-must-' + type;
+ tile.setAttribute(attr, active ? '1' : '0');
+ tile.classList.toggle('must-' + type, !!active);
+ var selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude');
+ try {
+ var btn = tile.querySelector(selector);
+ if (btn){
+ btn.setAttribute('data-active', active ? '1' : '0');
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ btn.classList.toggle('is-active', !!active);
+ }
+ } catch(_){ }
+ }
+
+ function restoreMustHaveState(tile, state){
+ if (!tile || !state) return;
+ setTileState(tile, 'include', state.include ? 1 : 0);
+ setTileState(tile, 'exclude', state.exclude ? 1 : 0);
+ }
+
+ function applyLocalMustHave(tile, type, enabled){
+ if (!tile) return;
+ if (type === 'include'){
+ setTileState(tile, 'include', enabled ? 1 : 0);
+ if (enabled){ setTileState(tile, 'exclude', 0); }
+ } else if (type === 'exclude'){
+ setTileState(tile, 'exclude', enabled ? 1 : 0);
+ if (enabled){ setTileState(tile, 'include', 0); }
+ }
+ }
+
+ function sendMustHaveRequest(tile, type, enabled, cardName, prevState){
+ if (!window.htmx){
+ restoreMustHaveState(tile, prevState);
+ tile.setAttribute('data-must-pending', '0');
+ toast('Offline: cannot update preference', 'error', { duration: 4000 });
+ return;
+ }
+ var summaryTarget = document.getElementById('include-exclude-summary');
+ var ajaxOptions = {
+ source: tile,
+ target: summaryTarget || tile,
+ swap: summaryTarget ? 'outerHTML' : 'none',
+ values: {
+ card_name: cardName,
+ list_type: type,
+ enabled: enabled ? '1' : '0',
+ },
+ };
+ var xhr;
+ try {
+ xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions);
+ } catch(_){
+ restoreMustHaveState(tile, prevState);
+ tile.setAttribute('data-must-pending', '0');
+ toast('Unable to submit preference update', 'error', { duration: 4500 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' });
+ return;
+ }
+ if (!xhr || !xhr.addEventListener){
+ tile.setAttribute('data-must-pending', '0');
+ return;
+ }
+ xhr.addEventListener('load', function(evt){
+ tile.setAttribute('data-must-pending', '0');
+ var request = evt && evt.currentTarget ? evt.currentTarget : xhr;
+ var status = request.status || 0;
+ if (status >= 400){
+ restoreMustHaveState(tile, prevState);
+ var msg = 'Failed to update preference';
+ try {
+ var data = JSON.parse(request.responseText || '{}');
+ if (data && data.error) msg = data.error;
+ } catch(_){ }
+ toast(msg, 'error', { duration: 5000 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status });
+ return;
+ }
+ var message;
+ if (enabled){
+ message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude';
+ } else {
+ message = (type === 'include') ? 'Removed must include' : 'Removed must exclude';
+ }
+ toast(message + ': ' + cardName, 'success', { duration: 2400 });
+ telemetry.send('must_have.toggle', {
+ card: cardName,
+ list: type,
+ enabled: enabled,
+ requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null,
+ });
+ });
+ xhr.addEventListener('error', function(){
+ tile.setAttribute('data-must-pending', '0');
+ restoreMustHaveState(tile, prevState);
+ toast('Network error updating preference', 'error', { duration: 5000 });
+ telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' });
+ });
+ }
+
+ function initMustHaveControls(root){
+ var scope = root && root.querySelectorAll ? root : document;
+ if (scope === document && document.body) scope = document.body;
+ if (!scope || !scope.querySelectorAll) return;
+ scope.querySelectorAll('.must-have-btn').forEach(function(btn){
+ if (!btn || btn.__mustHaveBound) return;
+ btn.__mustHaveBound = true;
+ var active = btn.getAttribute('data-active') === '1';
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ btn.addEventListener('click', function(ev){
+ ev.preventDefault();
+ var tile = btn.closest('.card-tile');
+ if (!tile) return;
+ if (tile.getAttribute('data-must-pending') === '1') return;
+ var type = btn.getAttribute('data-toggle');
+ if (!type) return;
+ var prevState = {
+ include: tile.getAttribute('data-must-include') === '1',
+ exclude: tile.getAttribute('data-must-exclude') === '1',
+ };
+ var nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude);
+ var label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || '';
+ tile.setAttribute('data-must-pending', '1');
+ applyLocalMustHave(tile, type, nextEnabled);
+ sendMustHaveRequest(tile, type, nextEnabled, label, prevState);
+ });
+ });
+ }
+
// LQIP blur/fade-in for thumbnails marked with data-lqip
document.addEventListener('DOMContentLoaded', function(){
try{
@@ -673,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 b0f6cc9..6992feb 100644
--- a/code/web/static/styles.css
+++ b/code/web/static/styles.css
@@ -235,7 +235,11 @@ small, .muted{ color: var(--muted); }
/* Skeletons */
[data-skeleton]{ position: relative; }
-[data-skeleton].is-loading > *{ opacity: 0; }
+[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; }
+[data-skeleton-placeholder]{ display:none; pointer-events:none; }
+[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; }
+[data-skeleton][data-skeleton-overlay="false"]::after,
+[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; }
[data-skeleton]::after{
content: '';
position: absolute; inset: 0;
@@ -246,6 +250,23 @@ small, .muted{ color: var(--muted); }
display: none;
}
[data-skeleton].is-loading::after{ display:block; }
+[data-skeleton].is-loading::before{
+ content: attr(data-skeleton-label);
+ position:absolute;
+ top:50%;
+ left:50%;
+ transform:translate(-50%, -50%);
+ color: var(--muted);
+ font-size:.85rem;
+ text-align:center;
+ line-height:1.4;
+ max-width:min(92%, 360px);
+ padding:.3rem .5rem;
+ pointer-events:none;
+ z-index:1;
+ filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
+}
+[data-skeleton][data-skeleton-label=""]::before{ content:''; }
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
@@ -268,6 +289,9 @@ small, .muted{ color: var(--muted); }
justify-content: start; /* pack as many as possible per row */
/* Prevent scroll chaining bounce that can cause flicker near bottom */
overscroll-behavior: contain;
+ content-visibility: auto;
+ contain: layout paint;
+ contain-intrinsic-size: 640px 420px;
}
@media (max-width: 420px){
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -289,10 +313,66 @@ small, .muted{ color: var(--muted); }
border-color: #f5e6a8; /* soft parchment gold */
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
}
+.card-tile.must-include{
+ border-color: rgba(74,222,128,.85);
+ box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2);
+}
+.card-tile.must-exclude{
+ border-color: rgba(239,68,68,.85);
+ box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset;
+ opacity: .95;
+}
+.card-tile.must-include.must-exclude{
+ border-color: rgba(249,115,22,.85);
+ box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset;
+}
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
+.must-have-controls{
+ display:flex;
+ justify-content:center;
+ gap:.35rem;
+ flex-wrap:wrap;
+ margin-top:.35rem;
+}
+.must-have-btn{
+ border:1px solid var(--border);
+ background:rgba(30,41,59,.6);
+ color:#f8fafc;
+ font-size:11px;
+ text-transform:uppercase;
+ letter-spacing:.06em;
+ padding:.25rem .6rem;
+ border-radius:9999px;
+ cursor:pointer;
+ transition: all .18s ease;
+}
+.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{
+ border-color: rgba(74,222,128,.75);
+ background: rgba(74,222,128,.18);
+ color: #bbf7d0;
+ box-shadow: 0 0 0 1px rgba(16,185,129,.25);
+}
+.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{
+ border-color: rgba(239,68,68,.75);
+ background: rgba(239,68,68,.18);
+ color: #fecaca;
+ box-shadow: 0 0 0 1px rgba(239,68,68,.25);
+}
+.must-have-btn:focus-visible{
+ outline:2px solid rgba(59,130,246,.6);
+ outline-offset:2px;
+}
+.card-tile.must-exclude .must-have-btn.include[data-active="0"],
+.card-tile.must-include .must-have-btn.exclude[data-active="0"]{
+ opacity:.65;
+}
+
+.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
+.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
+
/* Shared ownership badge for card tiles and stacked images */
.owned-badge{
position:absolute;
@@ -547,3 +627,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
@media (min-width: 900px) {
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
}
+
+/* Analytics accordion styling */
+.analytics-accordion {
+ transition: all 0.2s ease;
+}
+
+.analytics-accordion summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+}
+
+.analytics-accordion summary:hover {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+.analytics-accordion summary:active {
+ transform: scale(0.99);
+}
+
+.analytics-accordion[open] summary {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ margin-bottom: 0;
+}
+
+.analytics-accordion .analytics-content {
+ animation: accordion-slide-down 0.3s ease-out;
+}
+
+@keyframes accordion-slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.analytics-placeholder .skeleton-pulse {
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
diff --git a/code/web/templates/base.html b/code/web/templates/base.html
index f935e4d..576d9af 100644
--- a/code/web/templates/base.html
+++ b/code/web/templates/base.html
@@ -35,6 +35,9 @@
}catch(_){ }
})();
+
diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html
index 36e6ee4..025c6af 100644
--- a/code/web/templates/build/_alternatives.html
+++ b/code/web/templates/build/_alternatives.html
@@ -3,14 +3,15 @@
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
]
#}
-
+
Alternatives
{% set toggle_q = '0' if require_owned else '1' %}
{% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
{{ toggle_label }}
+ hx-target="closest .alts" hx-swap="outerHTML"
+ data-hx-cache="1" data-hx-cache-key="alts:{{ name|lower }}:owned:{{ toggle_q }}" data-hx-cache-ttl="20000">{{ toggle_label }}
New pool
@@ -18,7 +19,7 @@
{% if not items or items|length == 0 %}
No alternatives found{{ ' (owned only)' if require_owned else '' }}.
{% else %}
-
+
{% for it in items %}
{% set badge = '✔' if it.owned else '✖' %}
{% set title = 'Owned' if it.owned else 'Not owned' %}
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/_new_deck_candidates.html b/code/web/templates/build/_new_deck_candidates.html
index 23d9f87..7c68d49 100644
--- a/code/web/templates/build/_new_deck_candidates.html
+++ b/code/web/templates/build/_new_deck_candidates.html
@@ -5,6 +5,8 @@
{{ cand.display }}
{% if cand.warning %}
diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html
index a97bc1a..56c4d07 100644
--- a/code/web/templates/build/_new_deck_modal.html
+++ b/code/web/templates/build/_new_deck_modal.html
@@ -20,7 +20,9 @@
Commander
+ hx-get="/build/new/candidates" hx-trigger="debouncedinput change" hx-target="#newdeck-candidates" hx-sync="this:replace"
+ data-hx-debounce="220" data-hx-debounce-events="input"
+ data-hx-debounce-flush="blur" />
Start typing to see matches, then select one to load themes.
@@ -204,6 +206,11 @@
Enter one card name per line. Cards are validated against the database with smart name matching.
+ {% if not show_must_have_buttons %}
+
+ Step 5 quick-add buttons are hidden (SHOW_MUST_HAVE_BUTTONS=0), but you can still seed must include/exclude lists here.
+
+ {% endif %}
{% endif %}
Advanced options (ideals)
diff --git a/code/web/templates/build/_stage_navigator.html b/code/web/templates/build/_stage_navigator.html
index 6f74a34..db7f351 100644
--- a/code/web/templates/build/_stage_navigator.html
+++ b/code/web/templates/build/_stage_navigator.html
@@ -2,7 +2,7 @@
{% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %}
{% set index = step_index if step_index is defined else i if i is defined else 1 %}
{% set total = step_total if step_total is defined else n if n is defined else 5 %}
-
+
{% for idx in range(1, total+1) %}
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}
diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html
index c4eab9e..9757ff0 100644
--- a/code/web/templates/build/_step5.html
+++ b/code/web/templates/build/_step5.html
@@ -356,8 +356,9 @@
{% for c in g.list %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
-
+
+ {% if allow_must_haves and show_must_have_buttons %}
+
+ Must include
+ Must exclude
+
+ {% endif %}
{% if c.reason %}
Why?
- Alternatives
+ Alternatives
{{ c.reason }}
{% else %}
- Alternatives
+ Alternatives
{% endif %}
@@ -391,8 +402,9 @@
{% for c in added_cards %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
-
+
+ {% if allow_must_haves and show_must_have_buttons %}
+
+ Must include
+ Must exclude
+
+ {% endif %}
{% if c.reason %}
Why?
- Alternatives
+ Alternatives
{{ c.reason }}
{% else %}
- Alternatives
+ Alternatives
{% endif %}
@@ -420,7 +442,11 @@
{% endfor %}
{% endif %}
+ {% if allow_must_haves and show_must_have_buttons %}
+
Tip: Use the 🔒 Lock button to preserve the current copy in the deck. “Must include” will try to pull the card back in on future reruns, while “Must exclude” blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.
+ {% else %}
Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.
+ {% endif %}
No cards match your filters.
@@ -435,12 +461,17 @@
- {% if status and status.startswith('Build complete') and summary %}
-
- {% include "partials/include_exclude_summary.html" %}
-
- {% include "partials/deck_summary.html" %}
+ {% if allow_must_haves %}
+ {% include "partials/include_exclude_summary.html" with oob=False %}
{% endif %}
+
+
+ {% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
+
+
diff --git a/code/web/templates/build/index.html b/code/web/templates/build/index.html
index bbec2f8..788ff8b 100644
--- a/code/web/templates/build/index.html
+++ b/code/web/templates/build/index.html
@@ -9,7 +9,7 @@
← Back to Commanders
{% endif %}
-
+
Enable JavaScript to build a deck.
diff --git a/code/web/templates/commanders/index.html b/code/web/templates/commanders/index.html
index 95915b8..73c4bcc 100644
--- a/code/web/templates/commanders/index.html
+++ b/code/web/templates/commanders/index.html
@@ -45,30 +45,45 @@
Apply
-
-
Loading commanders…
-
- {% for i in range(3) %}
-
-
-
-
-
-
-
-
-
+
+
+
Loading commanders…
+
+ {% for i in range(3) %}
+
+
+
-
-
-
-
- {% endfor %}
+
+
+ {% endfor %}
+
-
-
- {% include "commanders/list_fragment.html" %}
+
+ {% include "commanders/list_fragment.html" %}
+
@@ -93,6 +108,11 @@
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
.commander-thumb { width:160px; flex:0 0 auto; position:relative; }
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
+ .commander-thumb-img.is-placeholder { animation: commander-thumb-pulse 1.2s ease-in-out infinite alternate; }
+ @keyframes commander-thumb-pulse {
+ from { filter:brightness(0.45); }
+ to { filter:brightness(0.65); }
+ }
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
.commander-name { margin:0; font-size:1.25rem; }
@@ -121,8 +141,8 @@
.theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
.theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
- .commander-loading { display:none; margin-top:1rem; }
- .commander-loading.htmx-request { display:block; }
+ .commander-loading { margin-top:1rem; gap:1rem; }
+ .commander-results-swap { display:flex; flex-direction:column; }
.commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; }
.commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
.skeleton-thumb { width:160px; height:220px; border-radius:10px; }
diff --git a/code/web/templates/commanders/row_wireframe.html b/code/web/templates/commanders/row_wireframe.html
index 9a7e87f..8f1d9ca 100644
--- a/code/web/templates/commanders/row_wireframe.html
+++ b/code/web/templates/commanders/row_wireframe.html
@@ -2,20 +2,37 @@
{% from "partials/_macros.html" import color_identity %}
{% set record = entry.record %}
{% set display_label = record.name if '//' in record.name else record.display_name %}
+{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
{% set small = record.image_small_url or record.image_normal_url %}
+ {% set normal = record.image_normal_url or small %}
+
+
+
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 854dd9d..6a12595 100644
--- a/code/web/templates/partials/include_exclude_summary.html
+++ b/code/web/templates/partials/include_exclude_summary.html
@@ -1,3 +1,10 @@
+{% 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 [] %}
+{% set has_pending = (pending_includes|length > 0) or (pending_excludes|length > 0) %}
+
{% if summary and summary.include_exclude_summary %}
{% set ie_summary = summary.include_exclude_summary %}
{% set has_data = (ie_summary.include_cards|length > 0) or (ie_summary.exclude_cards|length > 0) or (ie_summary.include_added|length > 0) or (ie_summary.excluded_removed|length > 0) %}
@@ -192,4 +199,45 @@
}
{% endif %}
+{% elif has_pending %}
+
+ Must-Have Selections
+
+
These card lists will apply to the next build run.
+
+
+
✓ Must Include ({{ pending_includes|length }})
+ {% if pending_includes|length %}
+
+ {% for card in pending_includes %}
+ {{ card }}
+ {% endfor %}
+
+ {% else %}
+
No include cards selected.
+ {% endif %}
+
+
+
✗ Must Exclude ({{ pending_excludes|length }})
+ {% if pending_excludes|length %}
+
+ {% for card in pending_excludes %}
+ {{ card }}
+ {% endfor %}
+
+ {% else %}
+
No exclude cards selected.
+ {% endif %}
+
+
+
+
+{% else %}
+ {% if show_must_have_buttons %}
+
+ Must-Have Selections
+ Use the ✓/✗ toggles on each card to mark must-have preferences.
+
+ {% endif %}
{% endif %}
+
diff --git a/code/web/templates/themes/picker.html b/code/web/templates/themes/picker.html
index ee50e8f..c596ad5 100644
--- a/code/web/templates/themes/picker.html
+++ b/code/web/templates/themes/picker.html
@@ -4,7 +4,8 @@
+ hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="debouncedinput change" name="q"
+ data-hx-debounce="260" data-hx-debounce-events="input,keyup" data-hx-debounce-flush="blur" data-hx-debounce-group="theme-search" />
All Archetypes
{% if archetypes %}{% for a in archetypes %}{{ a }} {% endfor %}{% endif %}
diff --git a/config/themes/theme_list.json b/config/themes/theme_list.json
index 5bb90da..1047f02 100644
--- a/config/themes/theme_list.json
+++ b/config/themes/theme_list.json
@@ -36,7 +36,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Stacks +1/+1 counters broadly then doubles, proliferates, or redistributes them for exponential scaling."
+ "description": "+1/+1 counters build across the board then get doubled, proliferated, or redistributed for exponential scaling. Synergies like Proliferate and Counters Matter reinforce the plan."
},
{
"id": "0-1-counters",
@@ -114,8 +114,8 @@
"The Ozolith",
"Evolution Sage",
"Cankerbloom",
- "Yawgmoth, Thran Physician",
"Thrummingbird",
+ "Yawgmoth, Thran Physician",
"Tezzeret's Gambit"
],
"synergy_commanders": [
@@ -123,7 +123,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Spreads -1/-1 counters for removal, attrition, and loop engines leveraging death & sacrifice triggers."
+ "description": "Spreads -1/-1 counters for removal, attrition, and loop engines leveraging death & sacrifice triggers. Synergies like Proliferate and Counters Matter reinforce the plan."
},
{
"id": "adamant",
@@ -352,7 +352,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Reduces spell costs via board resource counts (Affinity) enabling explosive early multi-spell turns."
+ "description": "Reduces spell costs via board resource counts (Affinity) enabling explosive early multi-spell turns. Synergies like Cost Reduction and Artifacts Matter reinforce the plan."
},
{
"id": "afflict",
@@ -416,7 +416,7 @@
"Imperious Oligarch",
"Seraph of the Scales",
"Knight of the Last Breath",
- "Syndicate Messenger"
+ "Orzhov Racketeers"
],
"synergy_commanders": [
"Sheoldred, the Apocalypse - Synergy (Aristocrats)"
@@ -458,7 +458,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Extracts two-phase value from split Aftermath spells, maximizing flexible sequencing."
+ "description": "Extracts two-phase value from split Aftermath spells, maximizing flexible sequencing. Synergies like Mill and Big Mana reinforce the plan."
},
{
"id": "age-counters",
@@ -532,7 +532,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Applies early pressure and combat tempo to close the game before slower value engines stabilize."
+ "description": "Applies early pressure and combat tempo to close the game before slower value engines stabilize. Synergies like Combat Matters and Voltron reinforce the plan."
},
{
"id": "airbending",
@@ -560,11 +560,11 @@
"id": "alien-kindred",
"theme": "Alien Kindred",
"synergies": [
- "Clones",
"Horror Kindred",
"Exile Matters",
"Trample",
- "Protection"
+ "Protection",
+ "Counters Matter"
],
"primary_color": "Blue",
"secondary_color": "Green",
@@ -586,12 +586,12 @@
"Time Beetle"
],
"synergy_commanders": [
- "Mondrak, Glory Dominus - Synergy (Clones)",
- "Kiki-Jiki, Mirror Breaker - Synergy (Clones)",
- "Sakashima of a Thousand Faces - Synergy (Clones)",
+ "Mondrak, Glory Dominus - Synergy (Horror Kindred)",
"Solphim, Mayhem Dominus - Synergy (Horror Kindred)",
"Zopandrel, Hunger Dominus - Synergy (Horror Kindred)",
- "Etali, Primal Storm - Synergy (Exile Matters)"
+ "Etali, Primal Storm - Synergy (Exile Matters)",
+ "Ragavan, Nimble Pilferer - Synergy (Exile Matters)",
+ "Ghalta, Primal Hunger - Synergy (Trample)"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -1084,7 +1084,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Generates artifact tokens as modular resources—fueling sacrifice, draw, and cost-reduction engines."
+ "description": "Generates artifact tokens as modular resources—fueling sacrifice, draw, and cost-reduction engines. Synergies like Treasure and Servo Kindred reinforce the plan."
},
{
"id": "artifacts-matter",
@@ -1122,7 +1122,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Leverages dense artifact counts for cost reduction, recursion, and modular scaling payoffs."
+ "description": "Leverages dense artifact counts for cost reduction, recursion, and modular scaling payoffs. Synergies like Treasure Token and Equipment Matters reinforce the plan."
},
{
"id": "artificer-kindred",
@@ -1557,7 +1557,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Pairs a Commander with Backgrounds for modular static buffs & class-style customization."
+ "description": "Pairs a Commander with Backgrounds for modular static buffs & class-style customization. Synergies like Choose a background and Treasure reinforce the plan."
},
{
"id": "backup",
@@ -1628,7 +1628,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Badger creatures into play with shared payoffs."
+ "description": "Focuses on getting a high number of Badger creatures into play with shared payoffs (e.g., Aggro and Combat Matters)."
},
{
"id": "banding",
@@ -2316,7 +2316,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts."
+ "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts. Synergies like Cost Reduction and Convoke reinforce the plan."
},
{
"id": "bird-kindred",
@@ -2406,7 +2406,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Recycles enter-the-battlefield triggers through blink/flicker loops for compounding value and soft locks."
+ "description": "Recycles enter-the-battlefield triggers through blink/flicker loops for compounding value and soft locks. Synergies like Enter the Battlefield and Flicker reinforce the plan."
},
{
"id": "blitz",
@@ -2481,7 +2481,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Bloodthirst and Bloodrush reinforce the plan."
},
{
"id": "bloodrush",
@@ -2515,7 +2515,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Uses Blood tokens to loot, set up graveyard recursion, and trigger discard/madness payoffs."
+ "description": "Uses Blood tokens to loot, set up graveyard recursion, and trigger discard/madness payoffs. Synergies like Blood Token and Aggro reinforce the plan."
},
{
"id": "bloodthirst",
@@ -2552,7 +2552,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Uses Blood tokens to loot, set up graveyard recursion, and trigger discard/madness payoffs."
+ "description": "Uses Blood tokens to loot, set up graveyard recursion, and trigger discard/madness payoffs. Synergies like Blood Token and +1/+1 Counters reinforce the plan."
},
{
"id": "boar-kindred",
@@ -2938,7 +2938,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Applies early pressure and combat tempo to close the game before slower value engines stabilize."
+ "description": "Applies early pressure and combat tempo to close the game before slower value engines stabilize. Synergies like Pingers and Bloodthirst reinforce the plan."
},
{
"id": "bushido",
@@ -3015,7 +3015,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Loops Buyback spells to convert excess mana into repeatable effects & inevitability."
+ "description": "Loops Buyback spells to convert excess mana into repeatable effects & inevitability. Synergies like Spells Matter and Spellslinger reinforce the plan."
},
{
"id": "ctan-kindred",
@@ -3192,8 +3192,8 @@
"Path of Discovery",
"Worldwalker Helm",
"Fanatical Offering",
- "Restless Anchorage",
"Amalia Benavides Aguirre",
+ "Restless Anchorage",
"Seasoned Dungeoneer"
],
"synergy_commanders": [
@@ -3266,7 +3266,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Chains cascade triggers to convert single casts into multi-spell value bursts."
+ "description": "Chains cascade triggers to convert single casts into multi-spell value bursts. Synergies like Exile Matters and Topdeck reinforce the plan."
},
{
"id": "cases-matter",
@@ -3370,7 +3370,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Cat creatures into play with shared payoffs (e.g., Forestwalk and Energy Counters)."
+ "description": "Focuses on getting a high number of Cat creatures into play with shared payoffs (e.g., Forestwalk and Vigilance)."
},
{
"id": "celebration",
@@ -3562,8 +3562,8 @@
"secondary_color": "Blue",
"example_commanders": [
"Hearthhull, the Worldseed",
- "Inspirit, Flagship Vessel",
"Dawnsire, Sunstar Dreadnought",
+ "Inspirit, Flagship Vessel",
"The Seriema",
"Infinite Guideline Station"
],
@@ -3590,8 +3590,7 @@
"id": "child-kindred",
"theme": "Child Kindred",
"synergies": [],
- "primary_color": "Black",
- "secondary_color": "Red",
+ "primary_color": "Red",
"example_cards": [
"Wee Champion"
],
@@ -3663,7 +3662,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Pairs a Commander with Backgrounds for modular static buffs & class-style customization."
+ "description": "Pairs a Commander with Backgrounds for modular static buffs & class-style customization. Synergies like Backgrounds Matter and Elf Kindred reinforce the plan."
},
{
"id": "chroma",
@@ -3998,7 +3997,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Investigate and Detective Kindred reinforce the plan."
},
{
"id": "cockatrice-kindred",
@@ -4218,19 +4217,6 @@
"editorial_quality": "draft",
"description": "Builds around Conditional Draw leveraging synergies with Max speed and Start your engines!."
},
- {
- "id": "conjure",
- "theme": "Conjure",
- "synergies": [],
- "primary_color": "Black",
- "secondary_color": "Blue",
- "example_cards": [
- "Silvanus's Invoker"
- ],
- "popularity_bucket": "Rare",
- "editorial_quality": "draft",
- "description": "Builds around the Conjure theme and its supporting synergies."
- },
{
"id": "connive",
"theme": "Connive",
@@ -4270,7 +4256,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Uses Connive looting + counters to sculpt hands, grow threats, and feed recursion lines."
+ "description": "Uses Connive looting + counters to sculpt hands, grow threats, and feed recursion lines. Synergies like Loot and Rogue Kindred reinforce the plan."
},
{
"id": "conspire",
@@ -4342,7 +4328,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Chains enchantment drops to trigger constellation loops in draw, drain, or scaling effects."
+ "description": "Chains enchantment drops to trigger constellation loops in draw, drain, or scaling effects. Synergies like Nymph Kindred and Enchantments Matter reinforce the plan."
},
{
"id": "construct-kindred",
@@ -4416,7 +4402,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Trades efficiently, accrues card advantage, and wins via inevitability once the board is stabilized."
+ "description": "Trades efficiently, accrues card advantage, and wins via inevitability once the board is stabilized. Synergies like Daybound and Nightbound reinforce the plan."
},
{
"id": "converge",
@@ -4492,8 +4478,8 @@
"id": "convoke",
"theme": "Convoke",
"synergies": [
- "Big Mana",
"Knight Kindred",
+ "Big Mana",
"Toolbox",
"Combat Tricks",
"Removal"
@@ -4505,7 +4491,7 @@
"The Wandering Rescuer",
"Hogaak, Arisen Necropolis",
"Kasla, the Broken Halo",
- "Syr Konrad, the Grim - Synergy (Big Mana)"
+ "Syr Konrad, the Grim - Synergy (Knight Kindred)"
],
"example_cards": [
"Chord of Calling",
@@ -4518,15 +4504,15 @@
"March of the Multitudes"
],
"synergy_commanders": [
- "Etali, Primal Storm - Synergy (Big Mana)",
- "Tatyova, Benthic Druid - Synergy (Big Mana)",
"Adeline, Resplendent Cathar - Synergy (Knight Kindred)",
"Danitha Capashen, Paragon - Synergy (Knight Kindred)",
+ "Etali, Primal Storm - Synergy (Big Mana)",
+ "Tatyova, Benthic Druid - Synergy (Big Mana)",
"Junji, the Midnight Sky - Synergy (Toolbox)"
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Converts creature presence into mana (Convoke) accelerating large or off-color spells."
+ "description": "Converts creature presence into mana (Convoke) accelerating large or off-color spells. Synergies like Big Mana and Knight Kindred reinforce the plan."
},
{
"id": "corpse-counters",
@@ -4723,7 +4709,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Stacks +1/+1 counters broadly then doubles, proliferates, or redistributes them for exponential scaling."
+ "description": "+1/+1 counters build across the board then get doubled, proliferated, or redistributed for exponential scaling. Synergies like Proliferate and +1/+1 Counters reinforce the plan."
},
{
"id": "counterspells",
@@ -4887,8 +4873,8 @@
"Exile Matters",
"Artifacts Matter"
],
- "primary_color": "Black",
- "secondary_color": "Blue",
+ "primary_color": "Blue",
+ "secondary_color": "Black",
"example_commanders": [
"Tetzin, Gnome Champion // The Golden-Gear Colossus",
"Octavia, Living Thesis - Synergy (Graveyard Matters)",
@@ -4911,7 +4897,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Transforms / upgrades permanents via Craft, banking latent value until a timing pivot."
+ "description": "Transforms / upgrades permanents via Craft, banking latent value until a timing pivot. Synergies like Graveyard Matters and Golem Kindred reinforce the plan."
},
{
"id": "creature-tokens",
@@ -4951,7 +4937,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Tokens Matter and Token Creation reinforce the plan."
},
{
"id": "crew",
@@ -5299,7 +5285,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Daybound leveraging synergies with Werewolf Kindred and Control."
+ "description": "Builds around Daybound leveraging synergies with Nightbound and Werewolf Kindred."
},
{
"id": "deathtouch",
@@ -5444,7 +5430,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Diversifies graveyard card types to unlock Delirium power thresholds."
+ "description": "Diversifies graveyard card types to unlock Delirium power thresholds. Synergies like Reanimate and Mill reinforce the plan."
},
{
"id": "delve",
@@ -5482,7 +5468,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Exiles graveyard cards to pay for Delve spells, converting stocked yard into mana efficiency."
+ "description": "Exiles graveyard cards to pay for Delve spells, converting stocked yard into mana efficiency. Synergies like Mill and Big Mana reinforce the plan."
},
{
"id": "demigod-kindred",
@@ -6017,7 +6003,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Leverages Discover to cheat spell mana values, chaining free cascade-like board development."
+ "description": "Leverages Discover to cheat spell mana values, chaining free cascade-like board development. Synergies like Land Types Matter and Exile Matters reinforce the plan."
},
{
"id": "disguise",
@@ -6215,7 +6201,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Doctor creatures into play with shared payoffs (e.g., Doctor's companion and Sagas Matter)."
+ "description": "Focuses on getting a high number of Doctor creatures into play with shared payoffs (e.g., Doctor's Companion and Doctor's companion)."
},
{
"id": "doctors-companion",
@@ -6349,7 +6335,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Assembles multiple basic land types rapidly to scale Domain-based effects."
+ "description": "Assembles multiple basic land types rapidly to scale Domain-based effects. Synergies like Lands Matter and Ramp reinforce the plan."
},
{
"id": "doom-counters",
@@ -6532,7 +6518,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops."
+ "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops. Synergies like Unconditional Draw and Reanimate reinforce the plan."
},
{
"id": "drone-kindred",
@@ -6682,22 +6668,6 @@
"editorial_quality": "draft",
"description": "Focuses on getting a high number of Dwarf creatures into play with shared payoffs (e.g., Servo Kindred and Berserker Kindred)."
},
- {
- "id": "earthbend",
- "theme": "Earthbend",
- "synergies": [
- "Landfall",
- "Ally Kindred",
- "Lands Matter",
- "+1/+1 Counters",
- "Blink"
- ],
- "primary_color": "Green",
- "secondary_color": "Black",
- "popularity_bucket": "Rare",
- "editorial_quality": "draft",
- "description": "Builds around Earthbend leveraging synergies with Landfall and Ally Kindred."
- },
{
"id": "earthbending",
"theme": "Earthbending",
@@ -7195,7 +7165,7 @@
"Token Creation"
],
"primary_color": "Black",
- "secondary_color": "Red",
+ "secondary_color": "Green",
"example_commanders": [
"Monoxa, Midway Manager",
"Captain Rex Nebula",
@@ -7254,7 +7224,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual."
+ "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual. Synergies like Umbra armor and Auras reinforce the plan."
},
{
"id": "enchantment-tokens",
@@ -7292,7 +7262,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual."
+ "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual. Synergies like Role token and Inspired reinforce the plan."
},
{
"id": "enchantments-matter",
@@ -7330,7 +7300,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual."
+ "description": "Stacks enchantment-based engines (cost reduction, constellation, aura recursion) for relentless value accrual. Synergies like Auras and Constellation reinforce the plan."
},
{
"id": "encore",
@@ -7440,7 +7410,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Accumulates Energy counters as a parallel resource spent for tempo spikes, draw, or scalable removal."
+ "description": "Accumulates Energy counters as a parallel resource spent for tempo spikes, draw, or scalable removal. Synergies like Resource Engine and Energy Counters reinforce the plan."
},
{
"id": "energy-counters",
@@ -7476,7 +7446,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Accumulates Energy counters as a parallel resource spent for tempo spikes, draw, or scalable removal."
+ "description": "Accumulates Energy counters as a parallel resource spent for tempo spikes, draw, or scalable removal. Synergies like Energy and Resource Engine reinforce the plan."
},
{
"id": "enlist",
@@ -7733,7 +7703,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Tutors and reuses equipment to stack stats/keywords onto resilient bodies for persistent pressure."
+ "description": "Tutors and reuses equipment to stack stats/keywords onto resilient bodies for persistent pressure. Synergies like Equip and Job select reinforce the plan."
},
{
"id": "equipment-matters",
@@ -7773,7 +7743,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Tutors and reuses equipment to stack stats/keywords onto resilient bodies for persistent pressure."
+ "description": "Tutors and reuses equipment to stack stats/keywords onto resilient bodies for persistent pressure. Synergies like Equipment and Equip reinforce the plan."
},
{
"id": "escalate",
@@ -7807,7 +7777,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Selects multiple modes on Escalate spells, trading mana/cards for flexible stacked effects."
+ "description": "Selects multiple modes on Escalate spells, trading mana/cards for flexible stacked effects. Synergies like Interaction and Spells Matter reinforce the plan."
},
{
"id": "escape",
@@ -7847,7 +7817,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Escapes threats from the graveyard by exiling spent resources, generating recursive inevitability."
+ "description": "Escapes threats from the graveyard by exiling spent resources, generating recursive inevitability. Synergies like Reanimate and Mill reinforce the plan."
},
{
"id": "eternalize",
@@ -7952,7 +7922,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Sequentially upgrades creatures with Evolve counters, then leverages accumulated stats or counter synergies."
+ "description": "Sequentially upgrades creatures with Evolve counters, then leverages accumulated stats or counter synergies. Synergies like +1/+1 Counters and Counters Matter reinforce the plan."
},
{
"id": "exalted",
@@ -8166,7 +8136,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Sacrifices creatures on ETB (Exploit) converting fodder into removal, draw, or recursion leverage."
+ "description": "Sacrifices creatures on ETB (Exploit) converting fodder into removal, draw, or recursion leverage. Synergies like Zombie Kindred and Sacrifice Matters reinforce the plan."
},
{
"id": "explore",
@@ -8203,7 +8173,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Uses Explore triggers to smooth draws, grow creatures, and feed graveyard-adjacent engines."
+ "description": "Uses Explore triggers to smooth draws, grow creatures, and feed graveyard-adjacent engines. Synergies like Map Token and Card Selection reinforce the plan."
},
{
"id": "extort",
@@ -8276,7 +8246,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Eye creatures into play with shared payoffs (e.g., More Than Meets the Eye and Convert)."
+ "description": "Focuses on getting a high number of Eye creatures into play with shared payoffs (e.g., Convert and Living metal)."
},
{
"id": "fabricate",
@@ -8664,8 +8634,8 @@
"example_cards": [
"Fire Lord Zuko",
"Zuko, Exiled Prince",
- "Avatar Aang // Aang, Master of Elements",
"The Rise of Sozin // Fire Lord Sozin",
+ "Avatar Aang // Aang, Master of Elements",
"Fire Nation Attacks",
"Fire Sages",
"Loyal Fire Sage",
@@ -8679,7 +8649,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Firebending leveraging synergies with Mana Dork and X Spells."
+ "description": "Builds around Firebending leveraging synergies with Bending and Mana Dork."
},
{
"id": "first-strike",
@@ -8707,8 +8677,8 @@
"Gisela, Blade of Goldnight",
"Bonehoard Dracosaur",
"Drana, Liberator of Malakir",
- "Thalia, Heretic Cathar",
- "Ocelot Pride"
+ "Ocelot Pride",
+ "Thalia, Heretic Cathar"
],
"synergy_commanders": [
"Ayesha Tanaka - Synergy (Banding)",
@@ -8879,7 +8849,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Replays instants & sorceries from the graveyard (Flashback) for incremental spell velocity."
+ "description": "Replays instants & sorceries from the graveyard (Flashback) for incremental spell velocity. Synergies like Reanimate and Mill reinforce the plan."
},
{
"id": "flood-counters",
@@ -9015,7 +8985,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Creates Food tokens for life padding and sacrifice loops that translate into drain, draw, or recursion."
+ "description": "Creates Food tokens for life padding and sacrifice loops that translate into drain, draw, or recursion. Synergies like Food Token and Forage reinforce the plan."
},
{
"id": "food-token",
@@ -9052,7 +9022,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Forage and Food reinforce the plan."
},
{
"id": "for-mirrodin",
@@ -9253,11 +9223,11 @@
"Delayed Blast Fireball",
"Ravenform",
"Saw It Coming",
- "Mystic Reflection",
"Behold the Multiverse",
+ "Mystic Reflection",
"Cosmic Intervention",
"Spectral Deluge",
- "Alrund's Epiphany"
+ "Doomskar"
],
"synergy_commanders": [
"Vito, Thorn of the Dusk Rose - Synergy (Cleric Kindred)",
@@ -9266,7 +9236,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Foretells spells early to smooth curve, conceal information, and discount impactful future turns."
+ "description": "Foretells spells early to smooth curve, conceal information, and discount impactful future turns. Synergies like Exile Matters and Cleric Kindred reinforce the plan."
},
{
"id": "formidable",
@@ -9341,7 +9311,7 @@
"Goro-Goro, Disciple of Ryusei - Synergy (Samurai Kindred)",
"Vito, Thorn of the Dusk Rose - Synergy (Cleric Kindred)"
],
- "popularity_bucket": "Niche",
+ "popularity_bucket": "Rare",
"editorial_quality": "draft",
"description": "Focuses on getting a high number of Fox creatures into play with shared payoffs (e.g., Bushido and Samurai Kindred)."
},
@@ -9810,7 +9780,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Gnome creatures into play with shared payoffs (e.g., Artifact Tokens and Creature Tokens)."
+ "description": "Focuses on getting a high number of Gnome creatures into play with shared payoffs (e.g., Artifact Tokens and Ramp)."
},
{
"id": "goad",
@@ -9850,7 +9820,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Redirects combat outward by goading opponents’ creatures, destabilizing defenses while you build advantage."
+ "description": "Redirects combat outward by goading opponents’ creatures, destabilizing defenses while you build advantage. Synergies like Theft and Rogue Kindred reinforce the plan."
},
{
"id": "goat-kindred",
@@ -9983,7 +9953,7 @@
"Golden Argosy",
"King Macar, the Gold-Cursed",
"Goldbug, Humanity's Ally // Goldbug, Scrappy Scout",
- "Tetzin, Gnome Champion // The Golden-Gear Colossus"
+ "Raksha Golden Cub"
],
"example_cards": [
"Curse of Opulence",
@@ -10005,7 +9975,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Artifact Tokens and Token Creation reinforce the plan."
},
{
"id": "golem-kindred",
@@ -10046,7 +10016,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Golem creatures into play with shared payoffs (e.g., Artificer Kindred and Artifact Tokens)."
+ "description": "Focuses on getting a high number of Golem creatures into play with shared payoffs (e.g., Craft and Graveyard Matters)."
},
{
"id": "gorgon-kindred",
@@ -10198,7 +10168,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops."
+ "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops. Synergies like Reanimate and Mill reinforce the plan."
},
{
"id": "gremlin-kindred",
@@ -10283,7 +10253,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Accelerates the whole table (cards / mana / tokens) to shape politics, then pivots that shared growth into asymmetric advantage."
+ "description": "Accelerates the whole table (cards / mana / tokens) to shape politics, then pivots that shared growth into asymmetric advantage. Synergies like Politics and Card Draw reinforce the plan."
},
{
"id": "growth-counters",
@@ -10310,7 +10280,7 @@
"Little Fellas"
],
"primary_color": "Black",
- "secondary_color": "Red",
+ "secondary_color": "Blue",
"example_commanders": [
"The Most Dangerous Gamer",
"The Space Family Goblinson",
@@ -10522,18 +10492,6 @@
"editorial_quality": "draft",
"description": "Builds around Haste leveraging synergies with Hellion Kindred and Phoenix Kindred."
},
- {
- "id": "hatching-counters",
- "theme": "Hatching Counters",
- "synergies": [],
- "primary_color": "Green",
- "example_cards": [
- "The Dragon-Kami Reborn // Dragon-Kami's Egg"
- ],
- "popularity_bucket": "Rare",
- "editorial_quality": "draft",
- "description": "Accumulates hatching counters to unlock scaling payoffs, removal triggers, or delayed value conversions."
- },
{
"id": "hatchling-counters",
"theme": "Hatchling Counters",
@@ -11018,7 +10976,7 @@
"Curious Homunculus // Voracious Reader",
"Filigree Attendant",
"Riddlekeeper",
- "Zndrsplt, Eye of Wisdom"
+ "Bonded Fetch"
],
"synergy_commanders": [
"Ragavan, Nimble Pilferer - Synergy (Little Fellas)",
@@ -11068,7 +11026,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Horror creatures into play with shared payoffs (e.g., Impending and Fear)."
+ "description": "Focuses on getting a high number of Horror creatures into play with shared payoffs (e.g., Impending and Alien Kindred)."
},
{
"id": "horse-kindred",
@@ -11259,8 +11217,8 @@
"Yannik, Scavenging Sentinel",
"Cackling Prowler",
"Kuldotha Cackler",
- "Hyena Pack",
"Trusty Companion",
+ "Hyena Pack",
"Gibbering Hyenas"
],
"popularity_bucket": "Rare",
@@ -11310,7 +11268,7 @@
"Toothy, Imaginary Friend",
"Kianne, Corrupted Memory",
"Pol Jamaar, Illusionist",
- "Cromat"
+ "Lyla, Holographic Assistant"
],
"example_cards": [
"Spark Double",
@@ -11460,7 +11418,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Taps artifacts as pseudo-mana (Improvise) to deploy oversized non-artifact spells ahead of curve."
+ "description": "Taps artifacts as pseudo-mana (Improvise) to deploy oversized non-artifact spells ahead of curve. Synergies like Artifacts Matter and Big Mana reinforce the plan."
},
{
"id": "impulse",
@@ -11568,7 +11526,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Banks Incubator tokens then transforms them into delayed board presence & artifact synergy triggers."
+ "description": "Banks Incubator tokens then transforms them into delayed board presence & artifact synergy triggers. Synergies like Incubator Token and Transform reinforce the plan."
},
{
"id": "incubator-token",
@@ -11604,7 +11562,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Incubate and Transform reinforce the plan."
},
{
"id": "indestructible",
@@ -11669,8 +11627,8 @@
"Evolution Sage",
"Cankerbloom",
"Etali, Primal Conqueror // Etali, Primal Sickness",
- "Yawgmoth, Thran Physician",
"Thrummingbird",
+ "Yawgmoth, Thran Physician",
"Tezzeret's Gambit"
],
"synergy_commanders": [
@@ -11680,7 +11638,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds."
+ "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds. Synergies like Poison Counters and Proliferate reinforce the plan."
},
{
"id": "infection-counters",
@@ -11945,7 +11903,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Banks Clue tokens for delayed card draw while fueling artifact & token synergies."
+ "description": "Banks Clue tokens for delayed card draw while fueling artifact & token synergies. Synergies like Clue Token and Detective Kindred reinforce the plan."
},
{
"id": "islandcycling",
@@ -12144,18 +12102,6 @@
"editorial_quality": "draft",
"description": "Builds around the Join forces theme and its supporting synergies."
},
- {
- "id": "judgment-counters",
- "theme": "Judgment Counters",
- "synergies": [],
- "primary_color": "White",
- "example_cards": [
- "Faithbound Judge // Sinner's Judgment"
- ],
- "popularity_bucket": "Rare",
- "editorial_quality": "draft",
- "description": "Accumulates judgment counters to unlock scaling payoffs, removal triggers, or delayed value conversions."
- },
{
"id": "juggernaut-kindred",
"theme": "Juggernaut Kindred",
@@ -12286,7 +12232,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Junk Tokens and Impulse reinforce the plan."
},
{
"id": "junk-tokens",
@@ -12324,7 +12270,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Junk Token and Impulse reinforce the plan."
},
{
"id": "kavu-kindred",
@@ -12439,7 +12385,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Kicker / Multikicker spells scale flexibly—paying extra mana for amplified late-game impact."
+ "description": "Kicker / Multikicker spells scale flexibly—paying extra mana for amplified late-game impact. Synergies like Kavu Kindred and Merfolk Kindred reinforce the plan."
},
{
"id": "kinship",
@@ -12839,7 +12785,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Abuses extra land drops and recursion to chain Landfall triggers and scale permanent-based payoffs."
+ "description": "Abuses extra land drops and recursion to chain Landfall triggers and scale permanent-based payoffs. Synergies like Lands Matter and Ramp reinforce the plan."
},
{
"id": "lands-matter",
@@ -12878,7 +12824,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Abuses extra land drops and recursion to chain Landfall triggers and scale permanent-based payoffs."
+ "description": "Abuses extra land drops and recursion to chain Landfall triggers and scale permanent-based payoffs. Synergies like Landfall and Domain reinforce the plan."
},
{
"id": "landwalk",
@@ -12951,7 +12897,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Uses Learn to toolbox from side selections (or discard/draw) enhancing adaptability & consistency."
+ "description": "Uses Learn to toolbox from side selections (or discard/draw) enhancing adaptability & consistency. Synergies like Discard Matters and Unconditional Draw reinforce the plan."
},
{
"id": "leave-the-battlefield",
@@ -13062,7 +13008,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Builds around Legends Matter leveraging synergies with Historics Matter and Superfriends."
+ "description": "Builds around Legends Matter leveraging synergies with Historics Matter and Backgrounds Matter."
},
{
"id": "level-counters",
@@ -13374,7 +13320,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Turns repeat lifegain triggers into card draw, scaling bodies, or drain-based win pressure."
+ "description": "Turns repeat lifegain triggers into card draw, scaling bodies, or drain-based win pressure. Synergies like Life Matters and Lifedrain reinforce the plan."
},
{
"id": "lifegain-triggers",
@@ -13414,7 +13360,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Turns repeat lifegain triggers into card draw, scaling bodies, or drain-based win pressure."
+ "description": "Turns repeat lifegain triggers into card draw, scaling bodies, or drain-based win pressure. Synergies like Vampire Kindred and Lifelink reinforce the plan."
},
{
"id": "lifelink",
@@ -13452,7 +13398,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Builds around Lifelink leveraging synergies with Lifegain Triggers and Lifegain."
+ "description": "Builds around Lifelink leveraging synergies with Lifegain Triggers and Vampire Kindred."
},
{
"id": "lifeloss",
@@ -13488,7 +13434,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Channels symmetrical life loss into card flow, recursion, and inevitability drains."
+ "description": "Channels symmetrical life loss into card flow, recursion, and inevitability drains. Synergies like Lifeloss Triggers and Bat Kindred reinforce the plan."
},
{
"id": "lifeloss-triggers",
@@ -13524,7 +13470,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Channels symmetrical life loss into card flow, recursion, and inevitability drains."
+ "description": "Channels symmetrical life loss into card flow, recursion, and inevitability drains. Synergies like Lifeloss and Bat Kindred reinforce the plan."
},
{
"id": "little-fellas",
@@ -13596,7 +13542,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Living metal leveraging synergies with Convert and Vehicles."
+ "description": "Builds around Living metal leveraging synergies with Convert and More Than Meets the Eye."
},
{
"id": "living-weapon",
@@ -13682,7 +13628,7 @@
"Connive"
],
"primary_color": "Blue",
- "secondary_color": "Black",
+ "secondary_color": "Red",
"example_commanders": [
"Baral, Chief of Compliance",
"The Locust God",
@@ -13788,7 +13734,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability."
+ "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability. Synergies like Planeswalkers and Superfriends reinforce the plan."
},
{
"id": "madness",
@@ -13825,7 +13771,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Turns discard into mana-efficient Madness casts, leveraging looting & Blood token filtering."
+ "description": "Turns discard into mana-efficient Madness casts, leveraging looting & Blood token filtering. Synergies like Discard Matters and Vampire Kindred reinforce the plan."
},
{
"id": "magecraft",
@@ -13850,8 +13796,8 @@
"Storm-Kiln Artist",
"Archmage Emeritus",
"Veyran, Voice of Duality",
- "Professor Onyx",
"Ashling, Flame Dancer",
+ "Professor Onyx",
"Sedgemoor Witch",
"Witherbloom Apprentice",
"Octavia, Living Thesis"
@@ -13865,7 +13811,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher."
+ "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher. Synergies like Transform and Wizard Kindred reinforce the plan."
},
{
"id": "mana-dork",
@@ -13940,7 +13886,7 @@
"Brudiclad, Telchor Engineer - Synergy (Myr Kindred)",
"Urtet, Remnant of Memnarch - Synergy (Myr Kindred)",
"Hearthhull, the Worldseed - Synergy (Charge Counters)",
- "Inspirit, Flagship Vessel - Synergy (Charge Counters)",
+ "Dawnsire, Sunstar Dreadnought - Synergy (Charge Counters)",
"Azusa, Lost but Seeking - Synergy (Ramp)"
],
"popularity_bucket": "Niche",
@@ -14089,7 +14035,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Explore and Card Selection reinforce the plan."
},
{
"id": "max-speed",
@@ -14464,7 +14410,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Maintains ≥3 artifacts to turn on Metalcraft efficiencies and scaling bonuses."
+ "description": "Maintains ≥3 artifacts to turn on Metalcraft efficiencies and scaling bonuses. Synergies like Transform and Artifacts Matter reinforce the plan."
},
{
"id": "metathran-kindred",
@@ -14528,7 +14474,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Uses flexible value threats & interaction, pivoting between pressure and attrition based on table texture."
+ "description": "Uses flexible value threats & interaction, pivoting between pressure and attrition based on table texture. Synergies like Proliferate and Support reinforce the plan."
},
{
"id": "mill",
@@ -14568,7 +14514,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Attacks libraries as a resource—looping self-mill or opponent mill into recursion and payoff engines."
+ "description": "Attacks libraries as a resource—looping self-mill or opponent mill into recursion and payoff engines. Synergies like Surveil and Threshold reinforce the plan."
},
{
"id": "minion-kindred",
@@ -14683,7 +14629,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Manipulates topdecks / draw timing to exploit Miracle cost reductions on splashy spells."
+ "description": "Manipulates topdecks / draw timing to exploit Miracle cost reductions on splashy spells. Synergies like Topdeck and Big Mana reinforce the plan."
},
{
"id": "mite-kindred",
@@ -14893,7 +14839,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Claims and defends the Monarch for sustained card draw with evasion & deterrents."
+ "description": "Claims and defends the Monarch for sustained card draw with evasion & deterrents. Synergies like Politics and Group Hug reinforce the plan."
},
{
"id": "monger-kindred",
@@ -14927,8 +14873,8 @@
"secondary_color": "Red",
"example_cards": [
"Nimble Mongoose",
- "Karoo Meerkat",
"Blurred Mongoose",
+ "Karoo Meerkat",
"Mongoose Lizard"
],
"popularity_bucket": "Rare",
@@ -15161,7 +15107,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around More Than Meets the Eye leveraging synergies with Convert and Eye Kindred."
+ "description": "Builds around More Than Meets the Eye leveraging synergies with Convert and Living metal."
},
{
"id": "morph",
@@ -15378,7 +15324,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Kicker / Multikicker spells scale flexibly—paying extra mana for amplified late-game impact."
+ "description": "Kicker / Multikicker spells scale flexibly—paying extra mana for amplified late-game impact. Synergies like +1/+1 Counters and Counters Matter reinforce the plan."
},
{
"id": "multiple-copies",
@@ -15484,7 +15430,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Stacks mutate layers to reuse mutate triggers and build a resilient evolving threat."
+ "description": "Stacks mutate layers to reuse mutate triggers and build a resilient evolving threat. Synergies like Beast Kindred and Flying reinforce the plan."
},
{
"id": "myr-kindred",
@@ -15688,7 +15634,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Nightbound leveraging synergies with Werewolf Kindred and Control."
+ "description": "Builds around Nightbound leveraging synergies with Daybound and Werewolf Kindred."
},
{
"id": "nightmare-kindred",
@@ -15875,7 +15821,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Noble creatures into play with shared payoffs (e.g., Vampire Kindred and Lifelink)."
+ "description": "Focuses on getting a high number of Noble creatures into play with shared payoffs (e.g., Lore Counters and Vampire Kindred)."
},
{
"id": "nomad-kindred",
@@ -16178,8 +16124,8 @@
"Acidic Slime",
"Scavenging Ooze",
"Necrotic Ooze",
- "Green Slime",
"Felix Five-Boots",
+ "Green Slime",
"Uchuulon",
"Ochre Jelly",
"Slime Against Humanity"
@@ -16513,7 +16459,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Overloads modal spells into one-sided board impacts or mass disruption swings."
+ "description": "Overloads modal spells into one-sided board impacts or mass disruption swings. Synergies like Combat Tricks and Removal reinforce the plan."
},
{
"id": "ox-kindred",
@@ -16698,7 +16644,7 @@
"+1/+1 Counters",
"Toughness Matters"
],
- "primary_color": "Blue",
+ "primary_color": "White",
"secondary_color": "Black",
"example_commanders": [
"Kodama of the East Tree",
@@ -16725,15 +16671,24 @@
"Sai, Master Thopterist - Synergy (Artificer Kindred)",
"Lotho, Corrupt Shirriff - Synergy (Outlaw Kindred)"
],
- "popularity_bucket": "Niche",
+ "popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Partner leveraging synergies with Partner with and Performer Kindred."
+ "description": "Builds around Partner leveraging synergies with Pirate Kindred and Artificer Kindred."
},
{
"id": "partner-father-son",
"theme": "Partner - Father & Son",
"synergies": [],
+ "example_commanders": [
+ "Atreus, Impulsive Son",
+ "Kratos, Stoic Father"
+ ],
+ "example_cards": [
+ "Atreus, Impulsive Son",
+ "Kratos, Stoic Father"
+ ],
"popularity_bucket": "Rare",
+ "editorial_quality": "draft",
"description": "Builds around the Partner - Father & Son theme and its supporting synergies."
},
{
@@ -16743,8 +16698,8 @@
"Blink",
"Enter the Battlefield",
"Leave the Battlefield",
- "Conditional Draw",
- "Warrior Kindred"
+ "Warrior Kindred",
+ "Outlaw Kindred"
],
"primary_color": "Blue",
"secondary_color": "Red",
@@ -16775,7 +16730,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Partner with leveraging synergies with Partner and Blink."
+ "description": "Builds around Partner with leveraging synergies with Blink and Enter the Battlefield."
},
{
"id": "peasant-kindred",
@@ -16889,7 +16844,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Performer creatures into play with shared payoffs (e.g., Partner and Blink)."
+ "description": "Focuses on getting a high number of Performer creatures into play with shared payoffs (e.g., Blink and Enter the Battlefield)."
},
{
"id": "persist",
@@ -17113,7 +17068,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Deploys deterrents and taxation effects to deflect aggression while assembling a protected win route."
+ "description": "Deploys deterrents and taxation effects to deflect aggression while assembling a protected win route. Synergies like Planeswalkers and Superfriends reinforce the plan."
},
{
"id": "pilot-kindred",
@@ -17163,7 +17118,7 @@
"Devil Kindred",
"Offspring",
"Burn",
- "Role token"
+ "Board Wipes"
],
"primary_color": "Red",
"secondary_color": "Black",
@@ -17200,8 +17155,8 @@
"Siren Kindred",
"Raid",
"Encore",
- "Outlaw Kindred",
- "Explore"
+ "Explore",
+ "Outlaw Kindred"
],
"primary_color": "Blue",
"secondary_color": "Red",
@@ -17332,7 +17287,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability."
+ "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability. Synergies like Proliferate and Superfriends reinforce the plan."
},
{
"id": "plant-kindred",
@@ -17449,7 +17404,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds."
+ "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds. Synergies like Toxic and Corrupted reinforce the plan."
},
{
"id": "politics",
@@ -17462,7 +17417,7 @@
"Monger Kindred"
],
"primary_color": "Black",
- "secondary_color": "White",
+ "secondary_color": "Red",
"example_commanders": [
"Braids, Arisen Nightmare",
"Loran of the Third Path",
@@ -17534,8 +17489,7 @@
"id": "porcupine-kindred",
"theme": "Porcupine Kindred",
"synergies": [],
- "primary_color": "Blue",
- "secondary_color": "Red",
+ "primary_color": "Red",
"example_cards": [
"Quilled Charger"
],
@@ -17594,7 +17548,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Artifact Tokens and Artificer Kindred reinforce the plan."
},
{
"id": "praetor-kindred",
@@ -17633,7 +17587,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Praetor creatures into play with shared payoffs (e.g., Phyrexian Kindred and Transform)."
+ "description": "Focuses on getting a high number of Praetor creatures into play with shared payoffs (e.g., Phyrexian Kindred and Lore Counters)."
},
{
"id": "primarch-kindred",
@@ -17705,8 +17659,8 @@
"Karn's Bastion",
"Evolution Sage",
"Cankerbloom",
- "Yawgmoth, Thran Physician",
"Thrummingbird",
+ "Yawgmoth, Thran Physician",
"Tezzeret's Gambit",
"Inexorable Tide",
"Flux Channeler"
@@ -17720,7 +17674,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Multiplies diverse counters (e.g., +1/+1, loyalty, poison) to escalate board state and inevitability."
+ "description": "Multiplies diverse counters (e.g., +1/+1, loyalty, poison) to escalate board state and inevitability. Synergies like Counters Matter and +1/+1 Counters reinforce the plan."
},
{
"id": "protection",
@@ -17869,7 +17823,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher."
+ "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher. Synergies like Spellslinger and Noncreature Spells reinforce the plan."
},
{
"id": "prowl",
@@ -17905,7 +17859,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Enables Prowl cost reductions via tribe-based combat connections, accelerating tempo sequencing."
+ "description": "Enables Prowl cost reductions via tribe-based combat connections, accelerating tempo sequencing. Synergies like Rogue Kindred and Outlaw Kindred reinforce the plan."
},
{
"id": "quest-counters",
@@ -18204,7 +18158,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts."
+ "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts. Synergies like Treasure Token and Land Tutors reinforce the plan."
},
{
"id": "rampage",
@@ -18236,7 +18190,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts."
+ "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts. Synergies like Big Mana reinforce the plan."
},
{
"id": "ranger-kindred",
@@ -18262,8 +18216,8 @@
"Halana and Alena, Partners",
"Quirion Ranger",
"Cadira, Caller of the Small",
- "Thornvault Forager",
"Temur Battlecrier",
+ "Thornvault Forager",
"Verge Rangers",
"Scryb Ranger"
],
@@ -18467,7 +18421,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops."
+ "description": "Loads high-impact cards into the graveyard early and reanimates them for explosive tempo or combo loops. Synergies like Mill and Graveyard Matters reinforce the plan."
},
{
"id": "rebel-kindred",
@@ -18541,7 +18495,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Uses Rebound to double-cast value spells, banking a delayed second resolution."
+ "description": "Uses Rebound to double-cast value spells, banking a delayed second resolution. Synergies like Exile Matters and Spells Matter reinforce the plan."
},
{
"id": "reconfigure",
@@ -18914,7 +18868,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Turns dead land draws into fuel by recasting Retrace spells for attrition resilience."
+ "description": "Turns dead land draws into fuel by recasting Retrace spells for attrition resilience. Synergies like Mill and Spells Matter reinforce the plan."
},
{
"id": "revolt",
@@ -19076,7 +19030,7 @@
"Eye Kindred"
],
"primary_color": "White",
- "secondary_color": "Red",
+ "secondary_color": "Blue",
"example_commanders": [
"Codsworth, Handy Helper",
"K-9, Mark I",
@@ -19090,8 +19044,8 @@
"Slicer, Hired Muscle // Slicer, High-Speed Antagonist",
"Clown Car",
"Tezzeret, Cruel Captain",
- "Securitron Squadron",
"Voyager Quickwelder",
+ "Securitron Squadron",
"Yes Man, Personal Securitron"
],
"synergy_commanders": [
@@ -19103,7 +19057,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Robot creatures into play with shared payoffs (e.g., More Than Meets the Eye and Clown Kindred)."
+ "description": "Focuses on getting a high number of Robot creatures into play with shared payoffs (e.g., Convert and Living metal)."
},
{
"id": "rogue-kindred",
@@ -19178,7 +19132,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Enchantment Tokens and Hero Kindred reinforce the plan."
},
{
"id": "roll-to-visit-your-attractions",
@@ -19224,7 +19178,7 @@
"Unholy Annex // Ritual Chamber",
"Charred Foyer // Warped Space"
],
- "popularity_bucket": "Niche",
+ "popularity_bucket": "Rare",
"editorial_quality": "draft",
"description": "Builds around Rooms Matter leveraging synergies with Eerie and Enchantments Matter."
},
@@ -19378,7 +19332,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Loops and resets Sagas to repeatedly harvest chapter-based value sequences."
+ "description": "Loops and resets Sagas to repeatedly harvest chapter-based value sequences. Synergies like Lore Counters and Read Ahead reinforce the plan."
},
{
"id": "salamander-kindred",
@@ -20119,7 +20073,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Shark creatures into play with shared payoffs (e.g., Stax and Toughness Matters)."
+ "description": "Focuses on getting a high number of Shark creatures into play with shared payoffs (e.g., Discard Matters and Stax)."
},
{
"id": "sheep-kindred",
@@ -20178,7 +20132,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Applies shield counters to insulate threats and create lopsided removal trades."
+ "description": "Applies shield counters to insulate threats and create lopsided removal trades. Synergies like Soldier Kindred and Counters Matter reinforce the plan."
},
{
"id": "shrines-matter",
@@ -20212,7 +20166,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Accumulates Shrines whose upkeep triggers scale multiplicatively into inevitability."
+ "description": "Accumulates Shrines whose upkeep triggers scale multiplicatively into inevitability. Synergies like Enchantments Matter reinforce the plan."
},
{
"id": "shroud",
@@ -20261,7 +20215,7 @@
"Outlaw Kindred",
"Flying",
"Artifacts Matter",
- "Toughness Matters"
+ "Little Fellas"
],
"primary_color": "Blue",
"example_commanders": [
@@ -20278,8 +20232,8 @@
"Spyglass Siren",
"Storm Fleet Negotiator",
"Malcolm, the Eyes",
- "Zephyr Singer",
- "Oaken Siren"
+ "Oaken Siren",
+ "Hypnotic Siren"
],
"synergy_commanders": [
"Captain Lannery Storm - Synergy (Pirate Kindred)",
@@ -20330,7 +20284,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Skeleton creatures into play with shared payoffs (e.g., Outlaw Kindred and Exile Matters)."
+ "description": "Focuses on getting a high number of Skeleton creatures into play with shared payoffs (e.g., Exile Matters and Mill)."
},
{
"id": "skulk",
@@ -20956,7 +20910,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher."
+ "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher. Synergies like Spellslinger and Cantrips reinforce the plan."
},
{
"id": "spellshaper-kindred",
@@ -21034,7 +20988,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher."
+ "description": "Chains cheap instants & sorceries for velocity—converting triggers into scalable damage or card advantage before a finisher. Synergies like Spells Matter and Prowess reinforce the plan."
},
{
"id": "sphinx-kindred",
@@ -21191,7 +21145,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Focuses on getting a high number of Spirit creatures into play with shared payoffs (e.g., Soulshift and Ki Counters)."
+ "description": "Focuses on getting a high number of Spirit creatures into play with shared payoffs (e.g., Soulshift and Disturb)."
},
{
"id": "splice",
@@ -21430,8 +21384,8 @@
"Swarmyard",
"Scurry Oak",
"Swarmyard Massacre",
- "Ravenous Squirrel",
"Hazel's Brewmaster",
+ "Ravenous Squirrel",
"Valley Rotcaller"
],
"synergy_commanders": [
@@ -21536,8 +21490,8 @@
"secondary_color": "Green",
"example_commanders": [
"Hearthhull, the Worldseed",
- "Inspirit, Flagship Vessel",
"Dawnsire, Sunstar Dreadnought",
+ "Inspirit, Flagship Vessel",
"The Seriema",
"Infinite Guideline Station"
],
@@ -21591,7 +21545,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Applies asymmetric resource denial (tax, tap, sacrifice, lock pieces) to throttle opponents while advancing a resilient engine."
+ "description": "Applies asymmetric resource denial (tax, tap, sacrifice, lock pieces) to throttle opponents while advancing a resilient engine. Synergies like Taxing Effects and Hatebears reinforce the plan."
},
{
"id": "storage-counters",
@@ -21658,7 +21612,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds storm count with cheap spells & mana bursts, converting it into a lethal payoff turn."
+ "description": "Builds storm count with cheap spells & mana bursts, converting it into a lethal payoff turn. Synergies like Spellslinger and Rituals reinforce the plan."
},
{
"id": "strive",
@@ -21736,22 +21690,6 @@
"editorial_quality": "draft",
"description": "Accumulates stun counters to unlock scaling payoffs, removal triggers, or delayed value conversions."
},
- {
- "id": "super-friends",
- "theme": "Super Friends",
- "synergies": [
- "Planeswalkers",
- "Superfriends",
- "Proliferate",
- "Myriad",
- "Loyalty Counters"
- ],
- "primary_color": "White",
- "secondary_color": "Blue",
- "popularity_bucket": "Common",
- "editorial_quality": "draft",
- "description": "Builds around Super Friends leveraging synergies with Planeswalkers and Superfriends."
- },
{
"id": "superfriends",
"theme": "Superfriends",
@@ -21762,8 +21700,8 @@
"Myriad",
"Loyalty Counters"
],
- "primary_color": "Red",
- "secondary_color": "White",
+ "primary_color": "White",
+ "secondary_color": "Blue",
"example_commanders": [
"Adeline, Resplendent Cathar",
"Yawgmoth, Thran Physician",
@@ -21785,9 +21723,9 @@
"Atraxa, Praetors' Voice - Synergy (Proliferate)",
"Ragavan, Nimble Pilferer - Synergy (Token Creation)"
],
- "popularity_bucket": "Uncommon",
+ "popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability."
+ "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability. Synergies like Planeswalkers and Proliferate reinforce the plan."
},
{
"id": "support",
@@ -21914,7 +21852,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Continuously filters with Surveil to sculpt draws, fuel recursion, and enable graveyard synergies."
+ "description": "Continuously filters with Surveil to sculpt draws, fuel recursion, and enable graveyard synergies. Synergies like Mill and Reanimate reinforce the plan."
},
{
"id": "survival",
@@ -22054,7 +21992,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Suspends spells early to pay off delayed powerful effects at discounted timing."
+ "description": "Suspends spells early to pay off delayed powerful effects at discounted timing. Synergies like Time Travel and Time Counters reinforce the plan."
},
{
"id": "swampcycling",
@@ -22280,7 +22218,7 @@
],
"popularity_bucket": "Common",
"editorial_quality": "draft",
- "description": "Acquires opponents’ permanents temporarily or permanently to convert their resources into board control."
+ "description": "Acquires opponents’ permanents temporarily or permanently to convert their resources into board control. Synergies like Goad and Sacrifice to Draw reinforce the plan."
},
{
"id": "thopter-kindred",
@@ -22354,7 +22292,7 @@
],
"popularity_bucket": "Niche",
"editorial_quality": "draft",
- "description": "Fills the graveyard quickly to meet Threshold counts and upgrade spell/creature efficiencies."
+ "description": "Fills the graveyard quickly to meet Threshold counts and upgrade spell/creature efficiencies. Synergies like Nomad Kindred and Minion Kindred reinforce the plan."
},
{
"id": "thrull-kindred",
@@ -22555,7 +22493,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Tokens Matter and Creature Tokens reinforce the plan."
},
{
"id": "token-modification",
@@ -22594,7 +22532,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Clones and Planeswalkers reinforce the plan."
},
{
"id": "tokens-matter",
@@ -22630,7 +22568,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Token Creation and Creature Tokens reinforce the plan."
},
{
"id": "toolbox",
@@ -22788,7 +22726,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds."
+ "description": "Leverages Infect/Toxic pressure and proliferate to accelerate poison win thresholds. Synergies like Poison Counters and Infect reinforce the plan."
},
{
"id": "toy-kindred",
@@ -22902,7 +22840,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts."
+ "description": "Accelerates mana ahead of curve, then converts surplus into oversized threats or multi-spell bursts. Synergies like Rhino Kindred and Wurm Kindred reinforce the plan."
},
{
"id": "transform",
@@ -22914,8 +22852,8 @@
"Battles Matter",
"Graveyard Matters"
],
- "primary_color": "Black",
- "secondary_color": "Red",
+ "primary_color": "White",
+ "secondary_color": "Blue",
"example_commanders": [
"Etali, Primal Conqueror // Etali, Primal Sickness",
"Ojer Taq, Deepest Foundation // Temple of Civilization",
@@ -23012,7 +22950,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Produces Treasure tokens as flexible ramp & combo fuel enabling explosive payoff turns."
+ "description": "Produces Treasure tokens as flexible ramp & combo fuel enabling explosive payoff turns. Synergies like Artifact Tokens and Sacrifice reinforce the plan."
},
{
"id": "treasure-token",
@@ -23052,7 +22990,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines."
+ "description": "Goes wide with creature tokens then converts mass into damage, draw, drain, or sacrifice engines. Synergies like Sacrifice Matters and Artifacts Matter reinforce the plan."
},
{
"id": "treefolk-kindred",
@@ -23755,7 +23693,7 @@
],
"popularity_bucket": "Uncommon",
"editorial_quality": "draft",
- "description": "Leverages efficient Vehicles and crew bodies to field evasive, sweep-resilient threats."
+ "description": "Leverages efficient Vehicles and crew bodies to field evasive, sweep-resilient threats. Synergies like Artifacts Matter and Crew reinforce the plan."
},
{
"id": "venture-into-the-dungeon",
@@ -23795,7 +23733,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Repeats Venture into the Dungeon steps to layer incremental room rewards into compounding advantage."
+ "description": "Repeats Venture into the Dungeon steps to layer incremental room rewards into compounding advantage. Synergies like Aggro and Combat Matters reinforce the plan."
},
{
"id": "verse-counters",
@@ -23855,8 +23793,8 @@
"Adeline, Resplendent Cathar",
"Faeburrow Elder",
"Elesh Norn, Grand Cenobite",
- "Boromir, Warden of the Tower",
"Enduring Vitality",
+ "Boromir, Warden of the Tower",
"Ojer Taq, Deepest Foundation // Temple of Civilization"
],
"synergy_commanders": [
@@ -23955,7 +23893,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Stacks auras, equipment, and protection on a single threat to push commander damage with layered resilience."
+ "description": "Stacks auras, equipment, and protection on a single threat to push commander damage with layered resilience. Synergies like Equipment Matters and Auras reinforce the plan."
},
{
"id": "wall-kindred",
@@ -24095,8 +24033,8 @@
"Codsworth, Handy Helper - Synergy (Robot Kindred)"
],
"example_cards": [
- "Exalted Sunborn",
"Weftstalker Ardent",
+ "Exalted Sunborn",
"Haliya, Guided by Light",
"Zoanthrope",
"Anticausal Vestige",
@@ -24189,7 +24127,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Builds around Waterbending leveraging synergies with Cost Reduction and Card Draw."
+ "description": "Builds around Waterbending leveraging synergies with Bending and Cost Reduction."
},
{
"id": "weasel-kindred",
@@ -24297,7 +24235,7 @@
"Ill-Tempered Loner // Howlpack Avenger",
"Avabruck Caretaker // Hollowhenge Huntmaster"
],
- "popularity_bucket": "Uncommon",
+ "popularity_bucket": "Niche",
"editorial_quality": "draft",
"description": "Focuses on getting a high number of Werewolf creatures into play with shared payoffs (e.g., Daybound and Nightbound)."
},
@@ -24375,7 +24313,7 @@
],
"popularity_bucket": "Very Common",
"editorial_quality": "draft",
- "description": "Loops mass draw/discard effects to refill, disrupt sculpted hands, and weaponize symmetrical replacement triggers."
+ "description": "Loops mass draw/discard effects to refill, disrupt sculpted hands, and weaponize symmetrical replacement triggers. Synergies like Discard Matters and Card Draw reinforce the plan."
},
{
"id": "will-of-the-planeswalkers",
@@ -24402,7 +24340,7 @@
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
- "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability."
+ "description": "Protects and reuses planeswalkers—amplifying loyalty via proliferate and recursion for inevitability. Synergies like Spells Matter and Spellslinger reinforce the plan."
},
{
"id": "will-of-the-council",
@@ -24954,10 +24892,10 @@
"Baron Sengir",
"Dwarven Armory",
"Tin-Wing Chimera",
- "Fungus Elemental",
"Brass-Talon Chimera",
- "Lead-Belly Chimera",
- "Iron-Heart Chimera"
+ "Fungus Elemental",
+ "Iron-Heart Chimera",
+ "Lead-Belly Chimera"
],
"popularity_bucket": "Rare",
"editorial_quality": "draft",
@@ -25573,36 +25511,36 @@
"Strike a Deal": 1
},
"blue": {
- "Blink": 573,
- "Enter the Battlefield": 573,
+ "Blink": 569,
+ "Enter the Battlefield": 569,
"Guest Kindred": 3,
- "Human Kindred": 546,
- "Leave the Battlefield": 573,
- "Little Fellas": 1439,
- "Outlaw Kindred": 219,
- "Rogue Kindred": 151,
+ "Human Kindred": 540,
+ "Leave the Battlefield": 569,
+ "Little Fellas": 1430,
+ "Outlaw Kindred": 217,
+ "Rogue Kindred": 150,
"Casualty": 5,
"Spell Copy": 78,
- "Spells Matter": 1726,
- "Spellslinger": 1726,
+ "Spells Matter": 1722,
+ "Spellslinger": 1722,
"Topdeck": 414,
"Bird Kindred": 148,
- "Flying": 771,
- "Toughness Matters": 908,
- "Aggro": 898,
+ "Flying": 767,
+ "Toughness Matters": 905,
+ "Aggro": 894,
"Aristocrats": 119,
- "Auras": 347,
- "Combat Matters": 898,
- "Enchant": 305,
- "Enchantments Matter": 735,
+ "Auras": 346,
+ "Combat Matters": 894,
+ "Enchant": 303,
+ "Enchantments Matter": 733,
"Midrange": 54,
"Sacrifice Matters": 110,
"Theft": 114,
- "Voltron": 598,
- "Big Mana": 1224,
+ "Voltron": 595,
+ "Big Mana": 1217,
"Elf Kindred": 11,
- "Mill": 564,
- "Reanimate": 495,
+ "Mill": 562,
+ "Reanimate": 493,
"Shaman Kindred": 11,
"Horror Kindred": 48,
"Insect Kindred": 7,
@@ -25612,16 +25550,16 @@
"Manifest dread": 9,
"Control": 666,
"Counterspells": 348,
- "Interaction": 899,
- "Stax": 915,
+ "Interaction": 896,
+ "Stax": 914,
"Fish Kindred": 43,
- "Flash": 169,
+ "Flash": 167,
"Probing Telepathy": 1,
"Protection": 158,
"Ward": 39,
"Threshold": 9,
- "Historics Matter": 292,
- "Legends Matter": 292,
+ "Historics Matter": 289,
+ "Legends Matter": 289,
"Noble Kindred": 13,
"Octopus Kindred": 42,
"Removal": 249,
@@ -25632,28 +25570,28 @@
"Scion Kindred": 6,
"Token Creation": 271,
"Tokens Matter": 272,
- "+1/+1 Counters": 223,
- "Counters Matter": 478,
+ "+1/+1 Counters": 221,
+ "Counters Matter": 475,
"Drake Kindred": 75,
"Kicker": 29,
- "Card Draw": 1050,
- "Discard Matters": 326,
- "Loot": 246,
- "Wizard Kindred": 526,
+ "Card Draw": 1046,
+ "Discard Matters": 325,
+ "Loot": 245,
+ "Wizard Kindred": 525,
"Cost Reduction": 144,
- "Artifacts Matter": 621,
+ "Artifacts Matter": 620,
"Equipment Matters": 90,
"Lands Matter": 198,
- "Conditional Draw": 196,
+ "Conditional Draw": 194,
"Defender": 69,
- "Draw Triggers": 171,
+ "Draw Triggers": 170,
"Wall Kindred": 41,
- "Wheels": 211,
+ "Wheels": 210,
"Artifact Tokens": 107,
"Thopter Kindred": 17,
- "Cantrips": 192,
- "Unconditional Draw": 449,
- "Board Wipes": 56,
+ "Cantrips": 191,
+ "Unconditional Draw": 448,
+ "Board Wipes": 55,
"Bracket:MassLandDenial": 8,
"Equipment": 25,
"Reconfigure": 3,
@@ -25669,12 +25607,12 @@
"Zombie Kindred": 83,
"Turtle Kindred": 21,
"Avatar Kindred": 14,
- "Exile Matters": 141,
+ "Exile Matters": 140,
"Suspend": 24,
"Time Counters": 32,
"Impulse": 11,
- "Soldier Kindred": 83,
- "Combat Tricks": 131,
+ "Soldier Kindred": 80,
+ "Combat Tricks": 129,
"Strive": 4,
"Cleric Kindred": 24,
"Enchantment Tokens": 11,
@@ -25682,7 +25620,7 @@
"Life Matters": 38,
"Lifegain": 38,
"Beast Kindred": 47,
- "Elemental Kindred": 110,
+ "Elemental Kindred": 109,
"Toolbox": 70,
"Energy": 24,
"Energy Counters": 22,
@@ -25692,34 +25630,34 @@
"Politics": 43,
"Servo Kindred": 1,
"Vedalken Kindred": 55,
- "Burn": 79,
+ "Burn": 78,
"Max speed": 4,
"Start your engines!": 4,
"Scry": 138,
"X Spells": 109,
- "Shapeshifter Kindred": 58,
+ "Shapeshifter Kindred": 57,
"Evoke": 6,
"Leviathan Kindred": 21,
"Whale Kindred": 17,
"Detective Kindred": 20,
"Sphinx Kindred": 61,
"Renew": 3,
- "Advisor Kindred": 32,
+ "Advisor Kindred": 31,
"Merfolk Kindred": 215,
"Robot Kindred": 20,
- "Stun Counters": 46,
+ "Stun Counters": 45,
"Cleave": 4,
"Spellshaper Kindred": 11,
"Reflection Kindred": 2,
"Storm": 9,
"Time Travel": 3,
"Domain": 6,
- "Siren Kindred": 20,
+ "Siren Kindred": 19,
"Backgrounds Matter": 13,
"Choose a background": 7,
"Halfling Kindred": 1,
- "Partner with": 9,
- "Vigilance": 50,
+ "Partner with": 8,
+ "Vigilance": 49,
"Bracket:ExtraTurn": 29,
"Foretell": 13,
"God Kindred": 8,
@@ -25728,7 +25666,7 @@
"Frog Kindred": 20,
"Salamander Kindred": 8,
"Encore": 4,
- "Pirate Kindred": 68,
+ "Pirate Kindred": 67,
"Warrior Kindred": 44,
"Treasure": 13,
"Treasure Token": 15,
@@ -25742,13 +25680,13 @@
"Dragon Kindred": 45,
"Elder Kindred": 4,
"Hexproof": 21,
- "Faerie Kindred": 81,
+ "Faerie Kindred": 80,
"Mana Dork": 47,
"Morph": 43,
- "Pingers": 23,
+ "Pingers": 22,
"Flood Counters": 3,
"Manifestation Counters": 1,
- "Clones": 145,
+ "Clones": 143,
"Cipher": 7,
"Prototype": 4,
"Learn": 4,
@@ -25763,9 +25701,9 @@
"Metalcraft": 8,
"Addendum": 3,
"Heroic": 10,
- "Convoke": 11,
+ "Convoke": 10,
"Angel Kindred": 3,
- "Spirit Kindred": 149,
+ "Spirit Kindred": 148,
"Nightmare Kindred": 17,
"Role token": 6,
"Infect": 34,
@@ -25779,7 +25717,7 @@
"Hero Kindred": 7,
"Job select": 4,
"Oil Counters": 12,
- "Alien Kindred": 8,
+ "Alien Kindred": 7,
"Planeswalkers": 72,
"Superfriends": 72,
"Amass": 13,
@@ -25820,7 +25758,7 @@
"Mana Rock": 22,
"Overload": 6,
"Haste": 2,
- "Homunculus Kindred": 21,
+ "Homunculus Kindred": 20,
"Rooms Matter": 12,
"Card Selection": 10,
"Explore": 10,
@@ -25874,7 +25812,7 @@
"Awaken": 5,
"Undaunted": 1,
"Kavu Kindred": 2,
- "Golem Kindred": 5,
+ "Golem Kindred": 4,
"Warp": 7,
"Lhurgoyf Kindred": 1,
"Pillowfort": 4,
@@ -25944,7 +25882,7 @@
"Freerunning": 2,
"Tiefling Kindred": 2,
"Two-Headed Coin": 1,
- "Monk Kindred": 20,
+ "Monk Kindred": 19,
"Pilot Kindred": 7,
"Multikicker": 3,
"Glimmer Kindred": 2,
@@ -26000,7 +25938,7 @@
"Efreet Kindred": 4,
"Horsemanship": 7,
"Demon Kindred": 2,
- "Discover": 3,
+ "Discover": 2,
"Tide Counters": 2,
"Camarid Kindred": 1,
"Weird Kindred": 4,
@@ -26022,7 +25960,7 @@
"Graveyard Matters": 5,
"Loyalty Counters": 7,
"Compleated": 1,
- "Replacement Draw": 3,
+ "Replacement Draw": 2,
"Cost Scaling": 5,
"Modal": 5,
"Spree": 5,
@@ -26158,8 +26096,7 @@
"Gust of Wind": 1,
"Coin Counters": 1,
"Archer Kindred": 1,
- "Hive Mind": 1,
- "Body-print": 1
+ "Hive Mind": 1
},
"black": {
"Blink": 757,
@@ -28019,370 +27956,12 @@
"generated_from": "merge (analytics + curated YAML + whitelist)",
"metadata_info": {
"mode": "merge",
- "generated_at": "2025-10-06T09:54:55",
- "curated_yaml_files": 739,
+ "generated_at": "2025-10-07T18:22:00",
+ "curated_yaml_files": 735,
"synergy_cap": 5,
"inference": "pmi",
"version": "phase-b-merge-v1",
- "catalog_hash": "e9f1a812ddd1e5ed543e9cd233132ac8f6d1aa28f0a476d80ea6fd71fc5f74a5"
+ "catalog_hash": "ae79af02508e2f9184aa74d63db5c9987fd65cfa87ce7adb50aec2f6ae8397c5"
},
- "description_fallback_summary": {
- "total_themes": 741,
- "generic_total": 285,
- "generic_with_synergies": 266,
- "generic_plain": 19,
- "generic_pct": 38.46,
- "top_generic_by_frequency": [
- {
- "theme": "Little Fellas",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 7126,
- "description": "Builds around Little Fellas leveraging synergies with Banding and Licid Kindred."
- },
- {
- "theme": "Combat Matters",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 6344,
- "description": "Builds around Combat Matters leveraging synergies with Aggro and Voltron."
- },
- {
- "theme": "Interaction",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 4142,
- "description": "Builds around Interaction leveraging synergies with Removal and Combat Tricks."
- },
- {
- "theme": "Toughness Matters",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 3482,
- "description": "Builds around Toughness Matters leveraging synergies with Defender and Egg Kindred."
- },
- {
- "theme": "Leave the Battlefield",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 3092,
- "description": "Builds around Leave the Battlefield leveraging synergies with Blink and Enter the Battlefield."
- },
- {
- "theme": "Enter the Battlefield",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 3088,
- "description": "Builds around Enter the Battlefield leveraging synergies with Blink and Reanimate."
- },
- {
- "theme": "Card Draw",
- "popularity_bucket": "Very Common",
- "synergy_count": 17,
- "total_frequency": 2699,
- "description": "Builds around Card Draw leveraging synergies with Loot and Wheels."
- },
- {
- "theme": "Life Matters",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 2388,
- "description": "Builds around Life Matters leveraging synergies with Lifegain and Lifedrain."
- },
- {
- "theme": "Flying",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 2213,
- "description": "Builds around Flying leveraging synergies with Phoenix Kindred and Archon Kindred."
- },
- {
- "theme": "Removal",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 1594,
- "description": "Builds around Removal leveraging synergies with Soulshift and Interaction."
- },
- {
- "theme": "Legends Matter",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 1536,
- "description": "Builds around Legends Matter leveraging synergies with Historics Matter and Superfriends."
- },
- {
- "theme": "Topdeck",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 1104,
- "description": "Builds around Topdeck leveraging synergies with Scry and Surveil."
- },
- {
- "theme": "Discard Matters",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 1050,
- "description": "Builds around Discard Matters leveraging synergies with Loot and Wheels."
- },
- {
- "theme": "Unconditional Draw",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 1045,
- "description": "Builds around Unconditional Draw leveraging synergies with Dredge and Learn."
- },
- {
- "theme": "Combat Tricks",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 857,
- "description": "Builds around Combat Tricks leveraging synergies with Flash and Strive."
- },
- {
- "theme": "Protection",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 806,
- "description": "Builds around Protection leveraging synergies with Ward and Hexproof."
- },
- {
- "theme": "Exile Matters",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 712,
- "description": "Builds around Exile Matters leveraging synergies with Impulse and Suspend."
- },
- {
- "theme": "Board Wipes",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 647,
- "description": "Builds around Board Wipes leveraging synergies with Bracket:MassLandDenial and Pingers."
- },
- {
- "theme": "Pingers",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 637,
- "description": "Builds around Pingers leveraging synergies with Extort and Devil Kindred."
- },
- {
- "theme": "Loot",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 523,
- "description": "Builds around Loot leveraging synergies with Card Draw and Discard Matters."
- },
- {
- "theme": "Cantrips",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 514,
- "description": "Builds around Cantrips leveraging synergies with Clue Token and Investigate."
- },
- {
- "theme": "X Spells",
- "popularity_bucket": "Very Common",
- "synergy_count": 5,
- "total_frequency": 505,
- "description": "Builds around X Spells leveraging synergies with Ravenous and Firebending."
- },
- {
- "theme": "Conditional Draw",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 459,
- "description": "Builds around Conditional Draw leveraging synergies with Max speed and Start your engines!."
- },
- {
- "theme": "Toolbox",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 453,
- "description": "Builds around Toolbox leveraging synergies with Entwine and Bracket:TutorNonland."
- },
- {
- "theme": "Cost Reduction",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 431,
- "description": "Builds around Cost Reduction leveraging synergies with Affinity and Freerunning."
- },
- {
- "theme": "Flash",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 429,
- "description": "Builds around Flash leveraging synergies with Evoke and Combat Tricks."
- },
- {
- "theme": "Haste",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 396,
- "description": "Builds around Haste leveraging synergies with Hellion Kindred and Phoenix Kindred."
- },
- {
- "theme": "Lifelink",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 396,
- "description": "Builds around Lifelink leveraging synergies with Lifegain Triggers and Lifegain."
- },
- {
- "theme": "Vigilance",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 396,
- "description": "Builds around Vigilance leveraging synergies with Angel Kindred and Mount Kindred."
- },
- {
- "theme": "Counterspells",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 395,
- "description": "Builds around Counterspells leveraging synergies with Control and Stax."
- },
- {
- "theme": "Mana Dork",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 336,
- "description": "Builds around Mana Dork leveraging synergies with Firebending and Scion Kindred."
- },
- {
- "theme": "Cycling",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 299,
- "description": "Builds around Cycling leveraging synergies with Landcycling and Basic landcycling."
- },
- {
- "theme": "Transform",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 296,
- "description": "Builds around Transform leveraging synergies with Incubator Token and Incubate."
- },
- {
- "theme": "Bracket:TutorNonland",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 293,
- "description": "Builds around Bracket:TutorNonland leveraging synergies with Transmute and Bracket:GameChanger."
- },
- {
- "theme": "Clones",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 282,
- "description": "Builds around Clones leveraging synergies with Myriad and Populate."
- },
- {
- "theme": "Scry",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 281,
- "description": "Builds around Scry leveraging synergies with Topdeck and Role token."
- },
- {
- "theme": "Reach",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 274,
- "description": "Builds around Reach leveraging synergies with Spider Kindred and Archer Kindred."
- },
- {
- "theme": "First strike",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 248,
- "description": "Builds around First strike leveraging synergies with Banding and Kithkin Kindred."
- },
- {
- "theme": "Politics",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 247,
- "description": "Builds around Politics leveraging synergies with Encore and Melee."
- },
- {
- "theme": "Defender",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 230,
- "description": "Builds around Defender leveraging synergies with Wall Kindred and Egg Kindred."
- },
- {
- "theme": "Menace",
- "popularity_bucket": "Common",
- "synergy_count": 5,
- "total_frequency": 225,
- "description": "Builds around Menace leveraging synergies with Warlock Kindred and Blood Token."
- },
- {
- "theme": "Deathtouch",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 191,
- "description": "Builds around Deathtouch leveraging synergies with Basilisk Kindred and Scorpion Kindred."
- },
- {
- "theme": "Land Types Matter",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 185,
- "description": "Builds around Land Types Matter leveraging synergies with Plainscycling and Mountaincycling."
- },
- {
- "theme": "Equip",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 184,
- "description": "Builds around Equip leveraging synergies with Job select and For Mirrodin!."
- },
- {
- "theme": "Spell Copy",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 182,
- "description": "Builds around Spell Copy leveraging synergies with Storm and Replicate."
- },
- {
- "theme": "Landwalk",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 170,
- "description": "Builds around Landwalk leveraging synergies with Swampwalk and Islandwalk."
- },
- {
- "theme": "Impulse",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 164,
- "description": "Builds around Impulse leveraging synergies with Junk Tokens and Junk Token."
- },
- {
- "theme": "Morph",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 140,
- "description": "Builds around Morph leveraging synergies with Beast Kindred and Illusion Kindred."
- },
- {
- "theme": "Devoid",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 114,
- "description": "Builds around Devoid leveraging synergies with Ingest and Processor Kindred."
- },
- {
- "theme": "Resource Engine",
- "popularity_bucket": "Uncommon",
- "synergy_count": 5,
- "total_frequency": 101,
- "description": "Builds around Resource Engine leveraging synergies with Energy and Energy Counters."
- }
- ]
- }
+ "description_fallback_summary": null
}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index ce30689..6fac0a4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,17 +15,18 @@ services:
# ------------------------------------------------------------------
# UI features/flags
- SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
- SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
- SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
- SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
- ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
- ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
- ENABLE_PRESETS: "0" # 1=show presets section
- ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
- USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
- WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
- ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
+ SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
+ SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
+ SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
+ SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
+ ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
+ ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
+ ENABLE_PRESETS: "0" # 1=show presets section
+ ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
+ USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
+ WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
+ ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
+ SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
# Partner / Background mechanics (feature flag)
diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml
index 0e5d083..4c841dd 100644
--- a/dockerhub-docker-compose.yml
+++ b/dockerhub-docker-compose.yml
@@ -20,7 +20,7 @@ services:
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
- SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
+ SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
@@ -28,6 +28,7 @@ services:
ENABLE_PRESETS: "0" # 1=show presets section
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
+ SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
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 content here
+
+
+
+
+```
+
+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)