feat: optimize must-have controls and commander catalog

This commit is contained in:
matt 2025-10-07 15:56:57 -07:00
parent b7bfc4ca09
commit 3877890889
23 changed files with 1150 additions and 87 deletions

View file

@ -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)
############################

View file

@ -14,14 +14,23 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Summary
- Phase 1 responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists.
- 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 200ms.
### Added
- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400ms 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`.
### Changed
- Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request.
- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks.
- Build wizard Step 5 now emits optimistic include/exclude updates using cached HTMX fragments, prefetch metadata, and persistent summary containers for pending must-have selections.
- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts.
- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset.
- Must-have include/exclude buttons are hidden by default behind a new `SHOW_MUST_HAVE_BUTTONS` env toggle and now ship with tooltips explaining how they differ from locks.
### Fixed
- _None_

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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
@ -684,6 +688,129 @@ 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
must_state = {
"includes": includes,
"excludes": excludes,
"enforcement_mode": sess.get("enforcement_mode") or "warn",
"allow_illegal": bool(sess.get("allow_illegal")),
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
}
ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False)
ctx["must_have_state"] = must_state
ctx["summary"] = None
response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx)
response.set_cookie("sid", sid, httponly=True, samesite="lax")
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:
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
except Exception:
pass
return response
# Alternatives cache moved to services/alts_utils
@ -1132,6 +1259,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 +1659,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 +1683,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 +1787,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 +1925,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,

View file

@ -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

View file

@ -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)

View file

@ -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,36 @@ 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", []))
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 +131,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,12 +365,42 @@ 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,

View file

@ -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,

View file

@ -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)

View file

@ -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 || {};
@ -197,14 +227,252 @@
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){
function dispatchHtmx(el, evtName, detail){
if (!el) return;
if (window.htmx && typeof window.htmx.trigger === 'function'){
window.htmx.trigger(el, evtName);
window.htmx.trigger(el, evtName, detail);
} else {
try { el.dispatchEvent(new Event(evtName, { bubbles: true })); } catch(_){ }
try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ }
}
}
function bindHtmxDebounce(el){
@ -296,6 +564,7 @@
initCardFilters(document);
initVirtualization(document);
initHtmxDebounce(document);
initMustHaveControls(document);
});
// Hydrate progress bars with width based on data-pct
@ -325,6 +594,7 @@
initCardFilters(e.target);
initVirtualization(e.target);
initHtmxDebounce(e.target);
initMustHaveControls(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
@ -787,6 +1057,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{

View file

@ -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;
@ -309,10 +313,63 @@ 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; }

View file

@ -35,6 +35,9 @@
}catch(_){ }
})();
</script>
<script>
window.__telemetryEndpoint = '/telemetry/events';
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>

View file

@ -10,7 +10,8 @@
{% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ toggle_q }}"
hx-target="closest .alts" hx-swap="outerHTML">{{ toggle_label }}</button>
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 }}</button>
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ 1 if require_owned else 0 }}&refresh=1"
hx-target="closest .alts" hx-swap="outerHTML" title="Request a fresh pool of alternatives">New pool</button>
</div>

View file

@ -5,6 +5,8 @@
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
data-hx-cache="1" data-hx-cache-key="newdeck:inspect:{{ cand.display|lower }}" data-hx-cache-ttl="45000"
data-hx-prefetch="/build/new/inspect?name={{ cand.display|urlencode }}"
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
{{ cand.display }}
{% if cand.warning %}

View file

@ -206,6 +206,11 @@
Enter one card name per line. Cards are validated against the database with smart name matching.
</small>
</fieldset>
{% if not show_must_have_buttons %}
<div class="muted" style="font-size:12px; margin-top:.75rem;">
Step 5 quick-add buttons are hidden (<code>SHOW_MUST_HAVE_BUTTONS=0</code>), but you can still seed must include/exclude lists here.
</div>
{% endif %}
{% endif %}
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>

View file

@ -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)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
@ -369,15 +370,25 @@
{% from 'partials/_macros.html' import lock_button %}
{{ lock_button(c.name, is_locked) }}
</div>
{% if allow_must_haves and show_must_have_buttons %}
<div class="must-have-controls" role="group" aria-label="Must have controls">
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
</div>
{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
@ -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)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}>
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
@ -404,15 +416,25 @@
{% from 'partials/_macros.html' import lock_button %}
{{ lock_button(c.name, is_locked) }}
</div>
{% if allow_must_haves and show_must_have_buttons %}
<div class="must-have-controls" role="group" aria-label="Must have controls">
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
</div>
{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% else %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
</div>
{% endif %}
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
@ -420,7 +442,11 @@
{% endfor %}
</div>
{% endif %}
{% if allow_must_haves and show_must_have_buttons %}
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">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.</div>
{% else %}
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">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.</div>
{% endif %}
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
No cards match your filters.
</div>
@ -435,10 +461,10 @@
<!-- controls now above -->
{% if status and status.startswith('Build complete') and summary %}
<!-- Include/Exclude Summary Panel (M3: Include/Exclude Summary Panel) -->
{% if allow_must_haves %}
{% include "partials/include_exclude_summary.html" %}
{% endif %}
{% if status and status.startswith('Build complete') and summary %}
{% include "partials/deck_summary.html" %}
{% endif %}
</div>

View file

@ -45,30 +45,45 @@
<button type="submit" class="btn filter-submit">Apply</button>
</form>
<div id="commander-loading" class="commander-loading" role="status" aria-live="polite">
<span class="sr-only">Loading commanders…</span>
<div class="commander-skeleton-list" aria-hidden="true">
{% for i in range(3) %}
<article class="commander-skeleton">
<div class="skeleton-thumb shimmer"></div>
<div class="skeleton-main">
<div class="skeleton-line skeleton-title shimmer"></div>
<div class="skeleton-line skeleton-meta shimmer"></div>
<div class="skeleton-chip-row">
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
<div
id="commander-results"
data-skeleton
data-skeleton-label=""
data-skeleton-delay="420"
data-skeleton-overlay="false"
aria-live="polite"
>
<div
id="commander-loading"
class="commander-loading"
role="status"
aria-live="polite"
data-skeleton-placeholder
>
<span class="sr-only">Loading commanders…</span>
<div class="commander-skeleton-list" aria-hidden="true">
{% for i in range(3) %}
<article class="commander-skeleton">
<div class="skeleton-thumb shimmer"></div>
<div class="skeleton-main">
<div class="skeleton-line skeleton-title shimmer"></div>
<div class="skeleton-line skeleton-meta shimmer"></div>
<div class="skeleton-chip-row">
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
<span class="skeleton-chip shimmer"></span>
</div>
<div class="skeleton-line skeleton-text shimmer"></div>
</div>
<div class="skeleton-line skeleton-text shimmer"></div>
</div>
<div class="skeleton-cta shimmer"></div>
</article>
{% endfor %}
<div class="skeleton-cta shimmer"></div>
</article>
{% endfor %}
</div>
</div>
</div>
<div id="commander-results">
{% include "commanders/list_fragment.html" %}
<div class="commander-results-swap" data-skeleton-content>
{% include "commanders/list_fragment.html" %}
</div>
</div>
</section>
@ -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; }

View file

@ -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==" %}
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
<div class="commander-thumb">
{% set small = record.image_small_url or record.image_normal_url %}
{% set normal = record.image_normal_url or small %}
<img
src="{{ small }}"
srcset="{{ small }} 160w, {{ record.image_normal_url or small }} 488w"
sizes="160px"
src="{{ placeholder_pixel }}"
alt="{{ record.display_name }} card art"
loading="lazy"
decoding="async"
width="160"
height="223"
data-lazy-image="commander"
data-lazy-src="{{ small }}"
data-lazy-srcset="{{ small }} 160w, {{ normal }} 488w"
data-lazy-sizes="(max-width: 900px) 60vw, 160px"
data-card-name="{{ record.display_name }}"
data-original-name="{{ record.name }}"
data-hover-simple="true"
class="commander-thumb-img"
/>
<noscript>
<img
src="{{ small }}"
srcset="{{ small }} 160w, {{ normal }} 488w"
sizes="160px"
alt="{{ record.display_name }} card art"
width="160"
height="223"
/>
</noscript>
</div>
<div class="commander-main">
<div class="commander-header">

View file

@ -1,3 +1,9 @@
<div id="include-exclude-summary" data-summary>
{% 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 +198,45 @@
}
</style>
{% endif %}
{% elif has_pending %}
<section style="margin-top:1rem;">
<h5>Must-Have Selections</h5>
<div class="impact-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="font-size:12px; margin-bottom:.5rem;">These card lists will apply to the next build run.</div>
<div style="display:flex; flex-direction:column; gap:.75rem;">
<div>
<div class="muted" style="font-weight:600; color:#4ade80; margin-bottom:.35rem;">✓ Must Include ({{ pending_includes|length }})</div>
{% if pending_includes|length %}
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in pending_includes %}
<span class="chip" style="background:#dcfce7; color:#166534; border:1px solid #bbf7d0;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
{% else %}
<div class="muted" style="font-size:12px;">No include cards selected.</div>
{% endif %}
</div>
<div>
<div class="muted" style="font-weight:600; color:#ef4444; margin-bottom:.35rem;">✗ Must Exclude ({{ pending_excludes|length }})</div>
{% if pending_excludes|length %}
<div class="ie-chips" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for card in pending_excludes %}
<span class="chip" style="background:#fee2e2; color:#dc2626; border:1px solid #fecaca;" data-card-name="{{ card }}">{{ card }}</span>
{% endfor %}
</div>
{% else %}
<div class="muted" style="font-size:12px;">No exclude cards selected.</div>
{% endif %}
</div>
</div>
</div>
</section>
{% else %}
{% if show_must_have_buttons %}
<section style="margin-top:1rem;">
<h5>Must-Have Selections</h5>
<div class="muted" style="font-size:12px;">Use the ✓/✗ toggles on each card to mark must-have preferences.</div>
</section>
{% endif %}
{% endif %}
</div>

View file

@ -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)

View file

@ -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